From 1dd2999e47bbf902999c0ff9bf4973589fc139fa Mon Sep 17 00:00:00 2001
From: Andrian Budantsov
Date: Wed, 11 Feb 2026 20:58:59 +0400
Subject: [PATCH 1/5] Add empty folder export, folder comments, and / escaping
in folder names
- Add AddFolder(folder, comment) API for creating empty folders and folder comments
- Add "Folder Comment" CSV column
- Support / in folder names by escaping as \/ in the joined path
- Reject folder segments ending with \ to prevent ambiguity with \/ escape
- Replace json.Marshal with jsonMarshal (SetEscapeHTML=false) to avoid
mangling &, <, > in JSON fields
- Preserve folder insertion order instead of sorting alphabetically
- Add functional test examples for manual import verification
- Add *.csv to .gitignore
---
.gitignore | 3 +
examples/functional/main.go | 114 +++++++++++++++++++++++
qacsv_test.go | 174 ++++++++++++++++++++++++++++++++----
qascsv.go | 110 +++++++++++++++++++----
4 files changed, 367 insertions(+), 34 deletions(-)
create mode 100644 examples/functional/main.go
diff --git a/.gitignore b/.gitignore
index 6ecd0fc..e7bea99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@
# .env files
.env
+
+# Generated CSV files
+*.csv
diff --git a/examples/functional/main.go b/examples/functional/main.go
new file mode 100644
index 0000000..2d56ac0
--- /dev/null
+++ b/examples/functional/main.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "log"
+
+ qascsv "github.com/hypersequent/qasphere-csv"
+)
+
+func main() {
+ generateFolderComments()
+ generateEmptyFolders()
+ generateEscaping()
+}
+
+func generateFolderComments() {
+ q := qascsv.NewQASphereCSV()
+
+ if err := q.AddFolder([]string{"Commented Folder"}, "This folder has a comment but also contains test cases"); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test inside commented folder",
+ Folder: []string{"Commented Folder"},
+ Priority: qascsv.PriorityHigh,
+ }); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.AddFolder([]string{"Another Folder"}, "Standalone comment on a folder with no test cases"); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.AddFolder([]string{"Parent", "Child With Comment"}, "Nested folder comment"); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test in parent folder",
+ Folder: []string{"Parent"},
+ Priority: qascsv.PriorityMedium,
+ }); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.WriteCSVToFile("folder_comments.csv"); err != nil {
+ log.Fatal(err)
+ }
+ log.Println("wrote folder_comments.csv")
+}
+
+func generateEmptyFolders() {
+ q := qascsv.NewQASphereCSV()
+
+ if err := q.AddFolder([]string{"Empty Root Folder"}, ""); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddFolder([]string{"Parent", "Empty Child"}, ""); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddFolder([]string{"Parent", "Another Empty Child"}, ""); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test in parent alongside empty children",
+ Folder: []string{"Parent"},
+ Priority: qascsv.PriorityLow,
+ }); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.AddFolder([]string{"Deep", "Nested", "Empty"}, ""); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.WriteCSVToFile("empty_folders.csv"); err != nil {
+ log.Fatal(err)
+ }
+ log.Println("wrote empty_folders.csv")
+}
+
+func generateEscaping() {
+ q := qascsv.NewQASphereCSV()
+
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test in folder with slash",
+ Folder: []string{"Features/Bugs", "Login"},
+ Priority: qascsv.PriorityHigh,
+ }); err != nil {
+ log.Fatal(err)
+ }
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test in folder with multiple slashes",
+ Folder: []string{"A/B/C", "D/E"},
+ Priority: qascsv.PriorityMedium,
+ }); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.AddFolder([]string{"Empty/Slash/Folder"}, "This empty folder name contains slashes"); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.AddTestCase(qascsv.TestCase{
+ Title: "Test in normal folder for comparison",
+ Folder: []string{"Normal Folder", "Subfolder"},
+ Priority: qascsv.PriorityLow,
+ }); err != nil {
+ log.Fatal(err)
+ }
+
+ if err := q.WriteCSVToFile("escaping.csv"); err != nil {
+ log.Fatal(err)
+ }
+ log.Println("wrote escaping.csv")
+}
diff --git a/qacsv_test.go b/qacsv_test.go
index 3764c34..abb1749 100644
--- a/qacsv_test.go
+++ b/qacsv_test.go
@@ -125,11 +125,11 @@ var successTestCases = []TestCase{
},
}
-const successTestCasesCSV = `Folder,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,Step 2,Expected 2
-root,standalone,tc-with-minimal-fields,,false,high,,,,,,,,,,,
-root,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",,,,action-1,,,expected-2
-root/child,standalone,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",preconditions,,,action-1,expected-1,action-2,expected-2
-root/child,standalone,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,,"action.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","expected.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,
+const successTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,Step 2,Expected 2
+root/child,,standalone,tc-with-all-fields,legacy-id,false,high,"tag1,tag2",[req1](http://req1),"[link-1](http://link1),[link-2](http://link2)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",preconditions,,,action-1,expected-1,action-2,expected-2
+root/child,,standalone,"tc-with-special-chars.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",legacy-id,false,high,"tag1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","[req.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;]()","[link-1.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;](http://link1)","[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]","preconditions.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,,"action.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;","expected.,<>/@$%""""''*&()[]{}+-[BACKTICK]!~;",,
+root,,standalone,tc-with-minimal-fields,,false,high,,,,,,,,,,,
+root,,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10},{""fileName"":""file-1.csv"",""id"":""file-id"",""url"":""http://file1"",""mimeType"":""text/csv"",""size"":10}]",,,,action-1,,,expected-2
`
var failureTestCases = []TestCase{
@@ -146,12 +146,12 @@ var failureTestCases = []TestCase{
Folder: []string{},
Priority: "high",
}, {
- Title: "folder with empty title",
- Folder: []string{"root/child"},
+ Title: "folder with empty segment",
+ Folder: []string{"root", ""},
Priority: "high",
}, {
- Title: "folder title with slash",
- Folder: []string{"root/child"},
+ Title: "folder segment ending with backslash",
+ Folder: []string{"root\\"},
Priority: "high",
}, {
Title: "wrong priority",
@@ -353,12 +353,12 @@ var customFieldSuccessTestCases = []TestCase{
},
}
-const customFieldSuccessTestCasesCSV = `Folder,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,custom_field_dropdown_test_env,custom_field_dropdown_automation,custom_field_text_notes
-custom-fields,standalone,tc-with-single-custom-field,,false,medium,,,,,,,,,,"{""value"":""staging"",""isDefault"":false}",,
-custom-fields,standalone,tc-with-multiple-custom-fields,,false,high,"regression,smoke",,,,,,,Execute test,Test passes,"{""value"":""production"",""isDefault"":false}","{""value"":""Automated"",""isDefault"":false}","{""value"":""This is a test note with special chars: !@#$%^\u0026*()"",""isDefault"":false}"
-custom-fields,standalone,tc-with-empty-custom-field-value,,false,low,,,,,,,,,,,,"{""value"":"""",""isDefault"":false}"
-custom-fields,standalone,tc-with-default-custom-field,,false,medium,,,,,,,,,,,"{""value"":"""",""isDefault"":false}",
-custom-fields/comprehensive,standalone,tc-with-all-fields-and-custom-fields,CF-001,false,high,"custom,comprehensive",[CF Requirements](http://cf-req),[CF Link](http://cf-link),"[{""fileName"":""cf-test.txt"",""id"":""cf-file-id"",""url"":""http://cf-file"",""mimeType"":""text/plain"",""size"":100}]",Custom field test setup,,,Step 1,Result 1,"{""value"":""development"",""isDefault"":false}","{""value"":""In Progress"",""isDefault"":false}",
+const customFieldSuccessTestCasesCSV = `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params,Step 1,Expected 1,custom_field_dropdown_test_env,custom_field_dropdown_automation,custom_field_text_notes
+custom-fields,,standalone,tc-with-single-custom-field,,false,medium,,,,,,,,,,"{""value"":""staging"",""isDefault"":false}",,
+custom-fields,,standalone,tc-with-multiple-custom-fields,,false,high,"regression,smoke",,,,,,,Execute test,Test passes,"{""value"":""production"",""isDefault"":false}","{""value"":""Automated"",""isDefault"":false}","{""value"":""This is a test note with special chars: !@#$%^&*()"",""isDefault"":false}"
+custom-fields,,standalone,tc-with-empty-custom-field-value,,false,low,,,,,,,,,,,,"{""value"":"""",""isDefault"":false}"
+custom-fields,,standalone,tc-with-default-custom-field,,false,medium,,,,,,,,,,,"{""value"":"""",""isDefault"":false}",
+custom-fields/comprehensive,,standalone,tc-with-all-fields-and-custom-fields,CF-001,false,high,"custom,comprehensive",[CF Requirements](http://cf-req),[CF Link](http://cf-link),"[{""fileName"":""cf-test.txt"",""id"":""cf-file-id"",""url"":""http://cf-file"",""mimeType"":""text/plain"",""size"":100}]",Custom field test setup,,,Step 1,Result 1,"{""value"":""development"",""isDefault"":false}","{""value"":""In Progress"",""isDefault"":false}",
`
var customFieldFailureTestCases = []TestCase{
@@ -456,3 +456,147 @@ func TestCustomFieldFailureTestCases(t *testing.T) {
})
}
}
+
+func TestFolderSlashEscaping(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddTestCase(TestCase{
+ Title: "tc-in-slash-folder",
+ Folder: []string{"root/parent", "child/leaf"},
+ Priority: "high",
+ })
+ require.NoError(t, err)
+
+ csv, err := qasCSV.GenerateCSV()
+ require.NoError(t, err)
+
+ expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params
+root\/parent/child\/leaf,,standalone,tc-in-slash-folder,,false,high,,,,,,,
+`
+ require.Equal(t, expected, csv)
+}
+
+func TestFolderSegmentEndingWithBackslash(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddTestCase(TestCase{
+ Title: "tc-bad-backslash",
+ Folder: []string{"root\\"},
+ Priority: "high",
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "must not end with '\\'")
+}
+
+func TestAddFolderEmpty(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddFolder([]string{"empty-folder"}, "")
+ require.NoError(t, err)
+
+ csv, err := qasCSV.GenerateCSV()
+ require.NoError(t, err)
+
+ expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params
+empty-folder,,,,,,,,,,,,,
+`
+ require.Equal(t, expected, csv)
+}
+
+func TestAddFolderWithComment(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddFolder([]string{"commented-folder"}, "This is a folder comment")
+ require.NoError(t, err)
+
+ csv, err := qasCSV.GenerateCSV()
+ require.NoError(t, err)
+
+ expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params
+commented-folder,This is a folder comment,,,,,,,,,,,,
+`
+ require.Equal(t, expected, csv)
+}
+
+func TestAddFolderWithCommentAndTestCases(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddFolder([]string{"my-folder"}, "Folder description")
+ require.NoError(t, err)
+
+ err = qasCSV.AddTestCase(TestCase{
+ Title: "tc-in-commented-folder",
+ Folder: []string{"my-folder"},
+ Priority: "high",
+ })
+ require.NoError(t, err)
+
+ csv, err := qasCSV.GenerateCSV()
+ require.NoError(t, err)
+
+ expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params
+my-folder,Folder description,,,,,,,,,,,,
+my-folder,,standalone,tc-in-commented-folder,,false,high,,,,,,,
+`
+ require.Equal(t, expected, csv)
+}
+
+func TestAddFolderValidation(t *testing.T) {
+ t.Run("empty folder path", func(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+ err := qasCSV.AddFolder([]string{}, "comment")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "folder path must not be empty")
+ })
+
+ t.Run("empty folder segment", func(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+ err := qasCSV.AddFolder([]string{"root", ""}, "comment")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "folder segment must not be empty")
+ })
+
+ t.Run("folder segment ending with backslash", func(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+ err := qasCSV.AddFolder([]string{"root\\"}, "comment")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "must not end with '\\'")
+ })
+
+ t.Run("duplicate folder", func(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+ err := qasCSV.AddFolder([]string{"root"}, "comment")
+ require.NoError(t, err)
+ err = qasCSV.AddFolder([]string{"root"}, "another comment")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "already exists")
+ })
+
+ t.Run("folder already has test cases", func(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+ err := qasCSV.AddTestCase(TestCase{
+ Title: "tc",
+ Folder: []string{"root"},
+ Priority: "high",
+ })
+ require.NoError(t, err)
+ err = qasCSV.AddFolder([]string{"root"}, "comment")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "already exists")
+ })
+}
+
+func TestAddFolderWithSlashInName(t *testing.T) {
+ qasCSV := NewQASphereCSV()
+
+ err := qasCSV.AddFolder([]string{"folder/with/slashes", "child"}, "slash comment")
+ require.NoError(t, err)
+
+ csv, err := qasCSV.GenerateCSV()
+ require.NoError(t, err)
+
+ expected := `Folder,Folder Comment,Type,Name,Legacy ID,Draft,Priority,Tags,Requirements,Links,Files,Preconditions,Parameter Values,Template Suffix Params
+folder\/with\/slashes/child,slash comment,,,,,,,,,,,,
+`
+ require.Equal(t, expected, csv)
+}
diff --git a/qascsv.go b/qascsv.go
index 553fb8c..733700f 100644
--- a/qascsv.go
+++ b/qascsv.go
@@ -3,12 +3,12 @@
package qascsv
import (
+ "bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
- "slices"
"strconv"
"strings"
@@ -20,7 +20,7 @@ import (
// staticColumns will always be present in the CSV file
// but there can be additional columns for steps and custom fields.
var staticColumns = []string{
- "Folder", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements",
+ "Folder", "Folder Comment", "Type", "Name", "Legacy ID", "Draft", "Priority", "Tags", "Requirements",
"Links", "Files", "Preconditions", "Parameter Values", "Template Suffix Params",
}
@@ -113,7 +113,7 @@ type TestCase struct {
// for reference. (optional)
LegacyID string `validate:"max=255"`
// The complete folder path to the test case. (required)
- Folder []string `validate:"min=1,dive,required,max=255,excludesall=/"`
+ Folder []string `validate:"min=1,dive,required,max=255"`
// The priority of the test case. (required)
Priority Priority `validate:"required,oneof=low medium high"`
// The tags to assign to the test cases. This can be used to group,
@@ -155,9 +155,11 @@ type TestCase struct {
// QASphereCSV provides APIs to generate CSV that can be used to import
// test cases in a project on QA Sphere.
type QASphereCSV struct {
- folderTCaseMap map[string][]TestCase
- validate *validator.Validate
- customFields []CustomField
+ folderTCaseMap map[string][]TestCase
+ folderCommentMap map[string]string
+ folderOrder []string
+ validate *validator.Validate
+ customFields []CustomField
numTCases int
maxSteps int
@@ -165,8 +167,9 @@ type QASphereCSV struct {
func NewQASphereCSV() *QASphereCSV {
return &QASphereCSV{
- folderTCaseMap: make(map[string][]TestCase),
- validate: validator.New(),
+ folderTCaseMap: make(map[string][]TestCase),
+ folderCommentMap: make(map[string]string),
+ validate: validator.New(),
}
}
@@ -235,6 +238,35 @@ func (q *QASphereCSV) AddTestCases(tcs []TestCase) error {
return nil
}
+func (q *QASphereCSV) AddFolder(folder []string, comment string) error {
+ if len(folder) == 0 {
+ return errors.New("folder path must not be empty")
+ }
+ for _, seg := range folder {
+ if seg == "" {
+ return errors.New("folder segment must not be empty")
+ }
+ if len(seg) > 255 {
+ return errors.Errorf("folder segment %q exceeds 255 character limit", seg)
+ }
+ }
+ if err := validateFolderSegments(folder); err != nil {
+ return err
+ }
+
+ folderPath := escapeFolderPath(folder)
+ if _, exists := q.folderTCaseMap[folderPath]; exists {
+ return errors.Errorf("folder %q already exists", folderPath)
+ }
+
+ q.folderOrder = append(q.folderOrder, folderPath)
+ q.folderTCaseMap[folderPath] = nil
+ if comment != "" {
+ q.folderCommentMap[folderPath] = comment
+ }
+ return nil
+}
+
func (q *QASphereCSV) GenerateCSV() (string, error) {
w := &strings.Builder{}
if err := q.writeCSV(w); err != nil {
@@ -257,7 +289,28 @@ func (q *QASphereCSV) WriteCSVToFile(file string) error {
return nil
}
+func validateFolderSegments(segments []string) error {
+ for _, seg := range segments {
+ if strings.HasSuffix(seg, `\`) {
+ return errors.Errorf("folder segment %q must not end with '\\'", seg)
+ }
+ }
+ return nil
+}
+
+func escapeFolderPath(segments []string) string {
+ escaped := make([]string, len(segments))
+ for i, seg := range segments {
+ escaped[i] = strings.ReplaceAll(seg, "/", `\/`)
+ }
+ return strings.Join(escaped, "/")
+}
+
func (q *QASphereCSV) validateTestCase(tc TestCase) error {
+ if err := validateFolderSegments(tc.Folder); err != nil {
+ return err
+ }
+
if tc.CustomFields != nil {
for systemName := range tc.CustomFields {
var found bool
@@ -277,7 +330,10 @@ func (q *QASphereCSV) validateTestCase(tc TestCase) error {
}
func (q *QASphereCSV) addTCase(tc TestCase) {
- folderPath := strings.Join(tc.Folder, "/")
+ folderPath := escapeFolderPath(tc.Folder)
+ if _, exists := q.folderTCaseMap[folderPath]; !exists {
+ q.folderOrder = append(q.folderOrder, folderPath)
+ }
q.folderTCaseMap[folderPath] = append(q.folderTCaseMap[folderPath], tc)
q.numTCases++
@@ -287,12 +343,17 @@ func (q *QASphereCSV) addTCase(tc TestCase) {
}
func (q *QASphereCSV) getFolders() []string {
- var folders []string
- for folder := range q.folderTCaseMap {
- folders = append(folders, folder)
+ return q.folderOrder
+}
+
+func jsonMarshal(v any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(v); err != nil {
+ return nil, err
}
- slices.Sort(folders)
- return folders
+ return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
}
func (q *QASphereCSV) getCSVRows() ([][]string, error) {
@@ -313,7 +374,18 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) {
folders := q.getFolders()
for _, f := range folders {
- for _, tc := range q.folderTCaseMap[f] {
+ tcs := q.folderTCaseMap[f]
+ comment := q.folderCommentMap[f]
+
+ // Write a folder-only row if the folder is empty or has a comment
+ if len(tcs) == 0 || comment != "" {
+ row := make([]string, numCols)
+ row[0] = f
+ row[1] = comment
+ rows = append(rows, row)
+ }
+
+ for _, tc := range tcs {
var requirements []string
for _, req := range tc.Requirements {
if req.Title == "" && req.URL == "" {
@@ -330,7 +402,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) {
var files string
if len(tc.Files) > 0 {
- filesb, err := json.Marshal(tc.Files)
+ filesb, err := jsonMarshal(tc.Files)
if err != nil {
return nil, errors.Wrap(err, "json marshal files")
}
@@ -339,7 +411,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) {
var parameterValues string
if len(tc.ParameterValues) > 0 {
- parameterValuesb, err := json.Marshal(tc.ParameterValues)
+ parameterValuesb, err := jsonMarshal(tc.ParameterValues)
if err != nil {
return nil, errors.Wrap(err, "json marshal parameter values")
}
@@ -347,7 +419,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) {
}
row := make([]string, 0, numCols)
- row = append(row, f, string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft),
+ row = append(row, f, "", string(tc.Type), tc.Title, tc.LegacyID, strconv.FormatBool(tc.Draft),
string(tc.Priority), strings.Join(tc.Tags, ","), strings.Join(requirements, ","),
strings.Join(links, ","), files, tc.Preconditions, parameterValues,
strings.Join(tc.FilledTCaseTitleSuffixParams, ","))
@@ -363,7 +435,7 @@ func (q *QASphereCSV) getCSVRows() ([][]string, error) {
customFieldCols := make([]string, len(customFieldsMap))
for systemName, cfValue := range tc.CustomFields {
- cfValueJSON, err := json.Marshal(cfValue)
+ cfValueJSON, err := jsonMarshal(cfValue)
if err != nil {
return nil, errors.Wrap(err, "json marshal custom field value")
}
From 838cf8f653cfebb7d84c2faaf2031074430d7a91 Mon Sep 17 00:00:00 2001
From: Andrian Budantsov
Date: Fri, 13 Feb 2026 18:22:44 +0400
Subject: [PATCH 2/5] Address PR review feedback from satvik007
- Introduce Folder struct with validation tags for AddFolder API
- Use go-validator consistently for folder validation (same as TestCase)
- Refactor examples/functional to return errors instead of log.Fatal
---
examples/functional/main.go | 91 ++++++++++++++++++++++++-------------
qacsv_test.go | 31 ++++++++-----
qascsv.go | 30 ++++++------
3 files changed, 94 insertions(+), 58 deletions(-)
diff --git a/examples/functional/main.go b/examples/functional/main.go
index 2d56ac0..64ec5fc 100644
--- a/examples/functional/main.go
+++ b/examples/functional/main.go
@@ -7,77 +7,102 @@ import (
)
func main() {
- generateFolderComments()
- generateEmptyFolders()
- generateEscaping()
+ if err := generateFolderComments(); err != nil {
+ log.Fatal(err)
+ }
+ if err := generateEmptyFolders(); err != nil {
+ log.Fatal(err)
+ }
+ if err := generateEscaping(); err != nil {
+ log.Fatal(err)
+ }
}
-func generateFolderComments() {
+func generateFolderComments() error {
q := qascsv.NewQASphereCSV()
- if err := q.AddFolder([]string{"Commented Folder"}, "This folder has a comment but also contains test cases"); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Commented Folder"},
+ Comment: "This folder has a comment but also contains test cases",
+ }); err != nil {
+ return err
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test inside commented folder",
Folder: []string{"Commented Folder"},
Priority: qascsv.PriorityHigh,
}); err != nil {
- log.Fatal(err)
+ return err
}
- if err := q.AddFolder([]string{"Another Folder"}, "Standalone comment on a folder with no test cases"); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Another Folder"},
+ Comment: "Standalone comment on a folder with no test cases",
+ }); err != nil {
+ return err
}
- if err := q.AddFolder([]string{"Parent", "Child With Comment"}, "Nested folder comment"); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Parent", "Child With Comment"},
+ Comment: "Nested folder comment",
+ }); err != nil {
+ return err
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in parent folder",
Folder: []string{"Parent"},
Priority: qascsv.PriorityMedium,
}); err != nil {
- log.Fatal(err)
+ return err
}
if err := q.WriteCSVToFile("folder_comments.csv"); err != nil {
- log.Fatal(err)
+ return err
}
log.Println("wrote folder_comments.csv")
+ return nil
}
-func generateEmptyFolders() {
+func generateEmptyFolders() error {
q := qascsv.NewQASphereCSV()
- if err := q.AddFolder([]string{"Empty Root Folder"}, ""); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Empty Root Folder"},
+ }); err != nil {
+ return err
}
- if err := q.AddFolder([]string{"Parent", "Empty Child"}, ""); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Parent", "Empty Child"},
+ }); err != nil {
+ return err
}
- if err := q.AddFolder([]string{"Parent", "Another Empty Child"}, ""); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Parent", "Another Empty Child"},
+ }); err != nil {
+ return err
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in parent alongside empty children",
Folder: []string{"Parent"},
Priority: qascsv.PriorityLow,
}); err != nil {
- log.Fatal(err)
+ return err
}
- if err := q.AddFolder([]string{"Deep", "Nested", "Empty"}, ""); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Deep", "Nested", "Empty"},
+ }); err != nil {
+ return err
}
if err := q.WriteCSVToFile("empty_folders.csv"); err != nil {
- log.Fatal(err)
+ return err
}
log.Println("wrote empty_folders.csv")
+ return nil
}
-func generateEscaping() {
+func generateEscaping() error {
q := qascsv.NewQASphereCSV()
if err := q.AddTestCase(qascsv.TestCase{
@@ -85,18 +110,21 @@ func generateEscaping() {
Folder: []string{"Features/Bugs", "Login"},
Priority: qascsv.PriorityHigh,
}); err != nil {
- log.Fatal(err)
+ return err
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in folder with multiple slashes",
Folder: []string{"A/B/C", "D/E"},
Priority: qascsv.PriorityMedium,
}); err != nil {
- log.Fatal(err)
+ return err
}
- if err := q.AddFolder([]string{"Empty/Slash/Folder"}, "This empty folder name contains slashes"); err != nil {
- log.Fatal(err)
+ if err := q.AddFolder(qascsv.Folder{
+ FolderPath: []string{"Empty/Slash/Folder"},
+ Comment: "This empty folder name contains slashes",
+ }); err != nil {
+ return err
}
if err := q.AddTestCase(qascsv.TestCase{
@@ -104,11 +132,12 @@ func generateEscaping() {
Folder: []string{"Normal Folder", "Subfolder"},
Priority: qascsv.PriorityLow,
}); err != nil {
- log.Fatal(err)
+ return err
}
if err := q.WriteCSVToFile("escaping.csv"); err != nil {
- log.Fatal(err)
+ return err
}
log.Println("wrote escaping.csv")
+ return nil
}
diff --git a/qacsv_test.go b/qacsv_test.go
index abb1749..f4b5b05 100644
--- a/qacsv_test.go
+++ b/qacsv_test.go
@@ -491,7 +491,7 @@ func TestFolderSegmentEndingWithBackslash(t *testing.T) {
func TestAddFolderEmpty(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"empty-folder"}, "")
+ err := qasCSV.AddFolder(Folder{FolderPath: []string{"empty-folder"}})
require.NoError(t, err)
csv, err := qasCSV.GenerateCSV()
@@ -506,7 +506,10 @@ empty-folder,,,,,,,,,,,,,
func TestAddFolderWithComment(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"commented-folder"}, "This is a folder comment")
+ err := qasCSV.AddFolder(Folder{
+ FolderPath: []string{"commented-folder"},
+ Comment: "This is a folder comment",
+ })
require.NoError(t, err)
csv, err := qasCSV.GenerateCSV()
@@ -521,7 +524,10 @@ commented-folder,This is a folder comment,,,,,,,,,,,,
func TestAddFolderWithCommentAndTestCases(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"my-folder"}, "Folder description")
+ err := qasCSV.AddFolder(Folder{
+ FolderPath: []string{"my-folder"},
+ Comment: "Folder description",
+ })
require.NoError(t, err)
err = qasCSV.AddTestCase(TestCase{
@@ -544,30 +550,28 @@ my-folder,,standalone,tc-in-commented-folder,,false,high,,,,,,,
func TestAddFolderValidation(t *testing.T) {
t.Run("empty folder path", func(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{}, "comment")
+ err := qasCSV.AddFolder(Folder{Comment: "comment"})
require.Error(t, err)
- require.Contains(t, err.Error(), "folder path must not be empty")
})
t.Run("empty folder segment", func(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"root", ""}, "comment")
+ err := qasCSV.AddFolder(Folder{FolderPath: []string{"root", ""}, Comment: "comment"})
require.Error(t, err)
- require.Contains(t, err.Error(), "folder segment must not be empty")
})
t.Run("folder segment ending with backslash", func(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"root\\"}, "comment")
+ err := qasCSV.AddFolder(Folder{FolderPath: []string{"root\\"}, Comment: "comment"})
require.Error(t, err)
require.Contains(t, err.Error(), "must not end with '\\'")
})
t.Run("duplicate folder", func(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"root"}, "comment")
+ err := qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "comment"})
require.NoError(t, err)
- err = qasCSV.AddFolder([]string{"root"}, "another comment")
+ err = qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "another comment"})
require.Error(t, err)
require.Contains(t, err.Error(), "already exists")
})
@@ -580,7 +584,7 @@ func TestAddFolderValidation(t *testing.T) {
Priority: "high",
})
require.NoError(t, err)
- err = qasCSV.AddFolder([]string{"root"}, "comment")
+ err = qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "comment"})
require.Error(t, err)
require.Contains(t, err.Error(), "already exists")
})
@@ -589,7 +593,10 @@ func TestAddFolderValidation(t *testing.T) {
func TestAddFolderWithSlashInName(t *testing.T) {
qasCSV := NewQASphereCSV()
- err := qasCSV.AddFolder([]string{"folder/with/slashes", "child"}, "slash comment")
+ err := qasCSV.AddFolder(Folder{
+ FolderPath: []string{"folder/with/slashes", "child"},
+ Comment: "slash comment",
+ })
require.NoError(t, err)
csv, err := qasCSV.GenerateCSV()
diff --git a/qascsv.go b/qascsv.go
index 733700f..a6cc52d 100644
--- a/qascsv.go
+++ b/qascsv.go
@@ -101,6 +101,14 @@ type CustomFieldValue struct {
IsDefault bool `json:"isDefault" validate:"omitempty"`
}
+// Folder represents a folder to be created in QA Sphere.
+type Folder struct {
+ // The folder path segments. (required)
+ FolderPath []string `validate:"min=1,dive,required,max=255"`
+ // An optional comment for the folder.
+ Comment string
+}
+
// TestCase represents a test case in QA Sphere.
type TestCase struct {
// The title of the test case. (required)
@@ -238,31 +246,23 @@ func (q *QASphereCSV) AddTestCases(tcs []TestCase) error {
return nil
}
-func (q *QASphereCSV) AddFolder(folder []string, comment string) error {
- if len(folder) == 0 {
- return errors.New("folder path must not be empty")
- }
- for _, seg := range folder {
- if seg == "" {
- return errors.New("folder segment must not be empty")
- }
- if len(seg) > 255 {
- return errors.Errorf("folder segment %q exceeds 255 character limit", seg)
- }
+func (q *QASphereCSV) AddFolder(f Folder) error {
+ if err := q.validate.Struct(f); err != nil {
+ return errors.Wrap(err, "folder validation")
}
- if err := validateFolderSegments(folder); err != nil {
+ if err := validateFolderSegments(f.FolderPath); err != nil {
return err
}
- folderPath := escapeFolderPath(folder)
+ folderPath := escapeFolderPath(f.FolderPath)
if _, exists := q.folderTCaseMap[folderPath]; exists {
return errors.Errorf("folder %q already exists", folderPath)
}
q.folderOrder = append(q.folderOrder, folderPath)
q.folderTCaseMap[folderPath] = nil
- if comment != "" {
- q.folderCommentMap[folderPath] = comment
+ if f.Comment != "" {
+ q.folderCommentMap[folderPath] = f.Comment
}
return nil
}
From 3fb795844373b6c7b8b467fa6d6075cf3631e904 Mon Sep 17 00:00:00 2001
From: Andrian Budantsov
Date: Fri, 13 Feb 2026 20:09:40 +0400
Subject: [PATCH 3/5] Rename TestCase.Folder to FolderPath for clarity
---
examples/basic/main.go | 6 ++--
examples/functional/main.go | 12 +++----
qacsv_test.go | 64 ++++++++++++++++++-------------------
qascsv.go | 6 ++--
4 files changed, 44 insertions(+), 44 deletions(-)
diff --git a/examples/basic/main.go b/examples/basic/main.go
index 8448993..0f876c6 100644
--- a/examples/basic/main.go
+++ b/examples/basic/main.go
@@ -14,7 +14,7 @@ func main() {
// Add a single test case
if err := qasCSV.AddTestCase(qascsv.TestCase{
Title: "Changing to corresponding cursor after hovering the element",
- Folder: []string{"Bistro Delivery", "About Us"},
+ FolderPath: []string{"Bistro Delivery", "About Us"},
Priority: "low",
Tags: []string{"About Us", "Checklist", "REQ-4", "UI"},
Preconditions: "The \"About Us\" page is opened",
@@ -28,7 +28,7 @@ func main() {
// Add multiple test cases
if err := qasCSV.AddTestCases([]qascsv.TestCase{{
Title: "Cart should be cleared after making the checkout",
- Folder: []string{"Bistro Delivery", "Cart", "Checkout"},
+ FolderPath: []string{"Bistro Delivery", "Cart", "Checkout"},
Priority: "medium",
Tags: []string{"Cart", "checkout", "REQ-6", "Functional"},
Preconditions: "1. Order is placed\n2. Successful message is shown",
@@ -41,7 +41,7 @@ func main() {
}},
}, {
Title: "Changing to corresponding cursor after hovering the element",
- Folder: []string{"Bistro Delivery", "Cart", "Checkout"},
+ FolderPath: []string{"Bistro Delivery", "Cart", "Checkout"},
Priority: "low",
Tags: []string{"Checklist", "REQ-6", "UI", "checkout"},
Preconditions: "The \"Checkout\" page is opened",
diff --git a/examples/functional/main.go b/examples/functional/main.go
index 64ec5fc..7e001e4 100644
--- a/examples/functional/main.go
+++ b/examples/functional/main.go
@@ -29,7 +29,7 @@ func generateFolderComments() error {
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test inside commented folder",
- Folder: []string{"Commented Folder"},
+ FolderPath: []string{"Commented Folder"},
Priority: qascsv.PriorityHigh,
}); err != nil {
return err
@@ -50,7 +50,7 @@ func generateFolderComments() error {
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in parent folder",
- Folder: []string{"Parent"},
+ FolderPath: []string{"Parent"},
Priority: qascsv.PriorityMedium,
}); err != nil {
return err
@@ -83,7 +83,7 @@ func generateEmptyFolders() error {
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in parent alongside empty children",
- Folder: []string{"Parent"},
+ FolderPath: []string{"Parent"},
Priority: qascsv.PriorityLow,
}); err != nil {
return err
@@ -107,14 +107,14 @@ func generateEscaping() error {
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in folder with slash",
- Folder: []string{"Features/Bugs", "Login"},
+ FolderPath: []string{"Features/Bugs", "Login"},
Priority: qascsv.PriorityHigh,
}); err != nil {
return err
}
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in folder with multiple slashes",
- Folder: []string{"A/B/C", "D/E"},
+ FolderPath: []string{"A/B/C", "D/E"},
Priority: qascsv.PriorityMedium,
}); err != nil {
return err
@@ -129,7 +129,7 @@ func generateEscaping() error {
if err := q.AddTestCase(qascsv.TestCase{
Title: "Test in normal folder for comparison",
- Folder: []string{"Normal Folder", "Subfolder"},
+ FolderPath: []string{"Normal Folder", "Subfolder"},
Priority: qascsv.PriorityLow,
}); err != nil {
return err
diff --git a/qacsv_test.go b/qacsv_test.go
index f4b5b05..7da935c 100644
--- a/qacsv_test.go
+++ b/qacsv_test.go
@@ -13,7 +13,7 @@ var successTestCases = []TestCase{
{
Title: "tc-with-all-fields",
LegacyID: "legacy-id",
- Folder: []string{"root", "child"},
+ FolderPath: []string{"root", "child"},
Priority: "high",
Tags: []string{"tag1", "tag2"},
Preconditions: "preconditions",
@@ -56,13 +56,13 @@ var successTestCases = []TestCase{
},
{
Title: "tc-with-minimal-fields",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
},
{
Title: "tc-with-special-chars.,<>/@$%\"\"''*&()[]{}+-`!~;",
LegacyID: "legacy-id",
- Folder: []string{"root", "child"},
+ FolderPath: []string{"root", "child"},
Priority: "high",
Tags: []string{"tag1.,<>/@$%\"\"''*&()[]{}+-`!~;"},
Preconditions: "preconditions.,<>/@$%\"\"''*&()[]{}+-`!~;",
@@ -92,7 +92,7 @@ var successTestCases = []TestCase{
},
{
Title: "tc-with-partial-fields",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "low",
Tags: []string{},
Preconditions: "",
@@ -135,71 +135,71 @@ root,,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileNam
var failureTestCases = []TestCase{
{
Title: "",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
}, {
Title: strings.Repeat("a", 512), // Exceeds 511 char limit
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
}, {
Title: "no folder",
- Folder: []string{},
+ FolderPath: []string{},
Priority: "high",
}, {
Title: "folder with empty segment",
- Folder: []string{"root", ""},
+ FolderPath: []string{"root", ""},
Priority: "high",
}, {
Title: "folder segment ending with backslash",
- Folder: []string{"root\\"},
+ FolderPath: []string{"root\\"},
Priority: "high",
}, {
Title: "wrong priority",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "very high",
}, {
Title: "empty tag",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Tags: []string{""},
}, {
Title: "long tag",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit
}, {
Title: "requirement without title and url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Requirements: []Requirement{{}},
}, {
Title: "requirement with invalid url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Requirements: []Requirement{{URL: "ftp://req1"}},
}, {
Title: "link without title and url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Links: []Link{{}},
}, {
Title: "link with no url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Links: []Link{{Title: "link-1"}},
}, {
Title: "link with no title",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Links: []Link{{URL: "http://link1"}},
}, {
Title: "link with invalid url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Links: []Link{{Title: "link-1", URL: "ftp://link1"}},
}, {
Title: "file without name",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Files: []File{
{
@@ -210,7 +210,7 @@ var failureTestCases = []TestCase{
},
}, {
Title: "file without id and url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Files: []File{
{
@@ -221,7 +221,7 @@ var failureTestCases = []TestCase{
},
}, {
Title: "file with invalid url",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
Files: []File{
{
@@ -253,7 +253,7 @@ var customFields = []CustomField{
var customFieldSuccessTestCases = []TestCase{
{
Title: "tc-with-single-custom-field",
- Folder: []string{"custom-fields"},
+ FolderPath: []string{"custom-fields"},
Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"test_env": {
@@ -264,7 +264,7 @@ var customFieldSuccessTestCases = []TestCase{
},
{
Title: "tc-with-multiple-custom-fields",
- Folder: []string{"custom-fields"},
+ FolderPath: []string{"custom-fields"},
Priority: "high",
Tags: []string{"regression", "smoke"},
CustomFields: map[string]CustomFieldValue{
@@ -290,7 +290,7 @@ var customFieldSuccessTestCases = []TestCase{
},
{
Title: "tc-with-empty-custom-field-value",
- Folder: []string{"custom-fields"},
+ FolderPath: []string{"custom-fields"},
Priority: "low",
CustomFields: map[string]CustomFieldValue{
"notes": {
@@ -301,7 +301,7 @@ var customFieldSuccessTestCases = []TestCase{
},
{
Title: "tc-with-default-custom-field",
- Folder: []string{"custom-fields"},
+ FolderPath: []string{"custom-fields"},
Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"automation": {
@@ -313,7 +313,7 @@ var customFieldSuccessTestCases = []TestCase{
{
Title: "tc-with-all-fields-and-custom-fields",
LegacyID: "CF-001",
- Folder: []string{"custom-fields", "comprehensive"},
+ FolderPath: []string{"custom-fields", "comprehensive"},
Priority: "high",
Tags: []string{"custom", "comprehensive"},
Preconditions: "Custom field test setup",
@@ -364,7 +364,7 @@ custom-fields/comprehensive,,standalone,tc-with-all-fields-and-custom-fields,CF-
var customFieldFailureTestCases = []TestCase{
{
Title: "tc-with-undefined-custom-field",
- Folder: []string{"custom-fields-errors"},
+ FolderPath: []string{"custom-fields-errors"},
Priority: "high",
CustomFields: map[string]CustomFieldValue{
"undefined_field": {
@@ -374,7 +374,7 @@ var customFieldFailureTestCases = []TestCase{
},
{
Title: "tc-with-very-long-custom-field-value",
- Folder: []string{"custom-fields-errors"},
+ FolderPath: []string{"custom-fields-errors"},
Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"notes": {
@@ -462,7 +462,7 @@ func TestFolderSlashEscaping(t *testing.T) {
err := qasCSV.AddTestCase(TestCase{
Title: "tc-in-slash-folder",
- Folder: []string{"root/parent", "child/leaf"},
+ FolderPath: []string{"root/parent", "child/leaf"},
Priority: "high",
})
require.NoError(t, err)
@@ -481,7 +481,7 @@ func TestFolderSegmentEndingWithBackslash(t *testing.T) {
err := qasCSV.AddTestCase(TestCase{
Title: "tc-bad-backslash",
- Folder: []string{"root\\"},
+ FolderPath: []string{"root\\"},
Priority: "high",
})
require.Error(t, err)
@@ -532,7 +532,7 @@ func TestAddFolderWithCommentAndTestCases(t *testing.T) {
err = qasCSV.AddTestCase(TestCase{
Title: "tc-in-commented-folder",
- Folder: []string{"my-folder"},
+ FolderPath: []string{"my-folder"},
Priority: "high",
})
require.NoError(t, err)
@@ -580,7 +580,7 @@ func TestAddFolderValidation(t *testing.T) {
qasCSV := NewQASphereCSV()
err := qasCSV.AddTestCase(TestCase{
Title: "tc",
- Folder: []string{"root"},
+ FolderPath: []string{"root"},
Priority: "high",
})
require.NoError(t, err)
diff --git a/qascsv.go b/qascsv.go
index a6cc52d..4dd68fc 100644
--- a/qascsv.go
+++ b/qascsv.go
@@ -121,7 +121,7 @@ type TestCase struct {
// for reference. (optional)
LegacyID string `validate:"max=255"`
// The complete folder path to the test case. (required)
- Folder []string `validate:"min=1,dive,required,max=255"`
+ FolderPath []string `validate:"min=1,dive,required,max=255"`
// The priority of the test case. (required)
Priority Priority `validate:"required,oneof=low medium high"`
// The tags to assign to the test cases. This can be used to group,
@@ -307,7 +307,7 @@ func escapeFolderPath(segments []string) string {
}
func (q *QASphereCSV) validateTestCase(tc TestCase) error {
- if err := validateFolderSegments(tc.Folder); err != nil {
+ if err := validateFolderSegments(tc.FolderPath); err != nil {
return err
}
@@ -330,7 +330,7 @@ func (q *QASphereCSV) validateTestCase(tc TestCase) error {
}
func (q *QASphereCSV) addTCase(tc TestCase) {
- folderPath := escapeFolderPath(tc.Folder)
+ folderPath := escapeFolderPath(tc.FolderPath)
if _, exists := q.folderTCaseMap[folderPath]; !exists {
q.folderOrder = append(q.folderOrder, folderPath)
}
From 154675b86ca5dcf9aae6cc81d783860785192849 Mon Sep 17 00:00:00 2001
From: Andrian Budantsov
Date: Fri, 13 Feb 2026 20:13:02 +0400
Subject: [PATCH 4/5] Fix gofumpt formatting
---
examples/functional/main.go | 24 ++++----
qacsv_test.go | 118 ++++++++++++++++++------------------
2 files changed, 71 insertions(+), 71 deletions(-)
diff --git a/examples/functional/main.go b/examples/functional/main.go
index 7e001e4..b19d413 100644
--- a/examples/functional/main.go
+++ b/examples/functional/main.go
@@ -28,9 +28,9 @@ func generateFolderComments() error {
return err
}
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test inside commented folder",
+ Title: "Test inside commented folder",
FolderPath: []string{"Commented Folder"},
- Priority: qascsv.PriorityHigh,
+ Priority: qascsv.PriorityHigh,
}); err != nil {
return err
}
@@ -49,9 +49,9 @@ func generateFolderComments() error {
return err
}
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test in parent folder",
+ Title: "Test in parent folder",
FolderPath: []string{"Parent"},
- Priority: qascsv.PriorityMedium,
+ Priority: qascsv.PriorityMedium,
}); err != nil {
return err
}
@@ -82,9 +82,9 @@ func generateEmptyFolders() error {
return err
}
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test in parent alongside empty children",
+ Title: "Test in parent alongside empty children",
FolderPath: []string{"Parent"},
- Priority: qascsv.PriorityLow,
+ Priority: qascsv.PriorityLow,
}); err != nil {
return err
}
@@ -106,16 +106,16 @@ func generateEscaping() error {
q := qascsv.NewQASphereCSV()
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test in folder with slash",
+ Title: "Test in folder with slash",
FolderPath: []string{"Features/Bugs", "Login"},
- Priority: qascsv.PriorityHigh,
+ Priority: qascsv.PriorityHigh,
}); err != nil {
return err
}
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test in folder with multiple slashes",
+ Title: "Test in folder with multiple slashes",
FolderPath: []string{"A/B/C", "D/E"},
- Priority: qascsv.PriorityMedium,
+ Priority: qascsv.PriorityMedium,
}); err != nil {
return err
}
@@ -128,9 +128,9 @@ func generateEscaping() error {
}
if err := q.AddTestCase(qascsv.TestCase{
- Title: "Test in normal folder for comparison",
+ Title: "Test in normal folder for comparison",
FolderPath: []string{"Normal Folder", "Subfolder"},
- Priority: qascsv.PriorityLow,
+ Priority: qascsv.PriorityLow,
}); err != nil {
return err
}
diff --git a/qacsv_test.go b/qacsv_test.go
index 7da935c..5b80714 100644
--- a/qacsv_test.go
+++ b/qacsv_test.go
@@ -55,9 +55,9 @@ var successTestCases = []TestCase{
Draft: false,
},
{
- Title: "tc-with-minimal-fields",
+ Title: "tc-with-minimal-fields",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
},
{
Title: "tc-with-special-chars.,<>/@$%\"\"''*&()[]{}+-`!~;",
@@ -134,39 +134,39 @@ root,,standalone,tc-with-partial-fields,,true,low,,[](http://req1),,"[{""fileNam
var failureTestCases = []TestCase{
{
- Title: "",
+ Title: "",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
}, {
- Title: strings.Repeat("a", 512), // Exceeds 511 char limit
+ Title: strings.Repeat("a", 512), // Exceeds 511 char limit
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
}, {
- Title: "no folder",
+ Title: "no folder",
FolderPath: []string{},
- Priority: "high",
+ Priority: "high",
}, {
- Title: "folder with empty segment",
+ Title: "folder with empty segment",
FolderPath: []string{"root", ""},
- Priority: "high",
+ Priority: "high",
}, {
- Title: "folder segment ending with backslash",
+ Title: "folder segment ending with backslash",
FolderPath: []string{"root\\"},
- Priority: "high",
+ Priority: "high",
}, {
- Title: "wrong priority",
+ Title: "wrong priority",
FolderPath: []string{"root"},
- Priority: "very high",
+ Priority: "very high",
}, {
- Title: "empty tag",
+ Title: "empty tag",
FolderPath: []string{"root"},
- Priority: "high",
- Tags: []string{""},
+ Priority: "high",
+ Tags: []string{""},
}, {
- Title: "long tag",
+ Title: "long tag",
FolderPath: []string{"root"},
- Priority: "high",
- Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit
+ Priority: "high",
+ Tags: []string{strings.Repeat("a", 256)}, // Exceeds 255 char limit
}, {
Title: "requirement without title and url",
FolderPath: []string{"root"},
@@ -178,29 +178,29 @@ var failureTestCases = []TestCase{
Priority: "high",
Requirements: []Requirement{{URL: "ftp://req1"}},
}, {
- Title: "link without title and url",
+ Title: "link without title and url",
FolderPath: []string{"root"},
- Priority: "high",
- Links: []Link{{}},
+ Priority: "high",
+ Links: []Link{{}},
}, {
- Title: "link with no url",
+ Title: "link with no url",
FolderPath: []string{"root"},
- Priority: "high",
- Links: []Link{{Title: "link-1"}},
+ Priority: "high",
+ Links: []Link{{Title: "link-1"}},
}, {
- Title: "link with no title",
+ Title: "link with no title",
FolderPath: []string{"root"},
- Priority: "high",
- Links: []Link{{URL: "http://link1"}},
+ Priority: "high",
+ Links: []Link{{URL: "http://link1"}},
}, {
- Title: "link with invalid url",
+ Title: "link with invalid url",
FolderPath: []string{"root"},
- Priority: "high",
- Links: []Link{{Title: "link-1", URL: "ftp://link1"}},
+ Priority: "high",
+ Links: []Link{{Title: "link-1", URL: "ftp://link1"}},
}, {
- Title: "file without name",
+ Title: "file without name",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
Files: []File{
{
MimeType: "text/csv",
@@ -209,9 +209,9 @@ var failureTestCases = []TestCase{
},
},
}, {
- Title: "file without id and url",
+ Title: "file without id and url",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
Files: []File{
{
Name: "file-1.csv",
@@ -220,9 +220,9 @@ var failureTestCases = []TestCase{
},
},
}, {
- Title: "file with invalid url",
+ Title: "file with invalid url",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
Files: []File{
{
Name: "file-1.csv",
@@ -252,9 +252,9 @@ var customFields = []CustomField{
var customFieldSuccessTestCases = []TestCase{
{
- Title: "tc-with-single-custom-field",
+ Title: "tc-with-single-custom-field",
FolderPath: []string{"custom-fields"},
- Priority: "medium",
+ Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"test_env": {
Value: "staging",
@@ -263,10 +263,10 @@ var customFieldSuccessTestCases = []TestCase{
},
},
{
- Title: "tc-with-multiple-custom-fields",
+ Title: "tc-with-multiple-custom-fields",
FolderPath: []string{"custom-fields"},
- Priority: "high",
- Tags: []string{"regression", "smoke"},
+ Priority: "high",
+ Tags: []string{"regression", "smoke"},
CustomFields: map[string]CustomFieldValue{
"test_env": {
Value: "production",
@@ -289,9 +289,9 @@ var customFieldSuccessTestCases = []TestCase{
},
},
{
- Title: "tc-with-empty-custom-field-value",
+ Title: "tc-with-empty-custom-field-value",
FolderPath: []string{"custom-fields"},
- Priority: "low",
+ Priority: "low",
CustomFields: map[string]CustomFieldValue{
"notes": {
Value: "",
@@ -300,9 +300,9 @@ var customFieldSuccessTestCases = []TestCase{
},
},
{
- Title: "tc-with-default-custom-field",
+ Title: "tc-with-default-custom-field",
FolderPath: []string{"custom-fields"},
- Priority: "medium",
+ Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"automation": {
Value: "",
@@ -363,9 +363,9 @@ custom-fields/comprehensive,,standalone,tc-with-all-fields-and-custom-fields,CF-
var customFieldFailureTestCases = []TestCase{
{
- Title: "tc-with-undefined-custom-field",
+ Title: "tc-with-undefined-custom-field",
FolderPath: []string{"custom-fields-errors"},
- Priority: "high",
+ Priority: "high",
CustomFields: map[string]CustomFieldValue{
"undefined_field": {
Value: "some value",
@@ -373,9 +373,9 @@ var customFieldFailureTestCases = []TestCase{
},
},
{
- Title: "tc-with-very-long-custom-field-value",
+ Title: "tc-with-very-long-custom-field-value",
FolderPath: []string{"custom-fields-errors"},
- Priority: "medium",
+ Priority: "medium",
CustomFields: map[string]CustomFieldValue{
"notes": {
Value: strings.Repeat("a", 256), // Exceeds 255 char limit
@@ -461,9 +461,9 @@ func TestFolderSlashEscaping(t *testing.T) {
qasCSV := NewQASphereCSV()
err := qasCSV.AddTestCase(TestCase{
- Title: "tc-in-slash-folder",
+ Title: "tc-in-slash-folder",
FolderPath: []string{"root/parent", "child/leaf"},
- Priority: "high",
+ Priority: "high",
})
require.NoError(t, err)
@@ -480,9 +480,9 @@ func TestFolderSegmentEndingWithBackslash(t *testing.T) {
qasCSV := NewQASphereCSV()
err := qasCSV.AddTestCase(TestCase{
- Title: "tc-bad-backslash",
+ Title: "tc-bad-backslash",
FolderPath: []string{"root\\"},
- Priority: "high",
+ Priority: "high",
})
require.Error(t, err)
require.Contains(t, err.Error(), "must not end with '\\'")
@@ -531,9 +531,9 @@ func TestAddFolderWithCommentAndTestCases(t *testing.T) {
require.NoError(t, err)
err = qasCSV.AddTestCase(TestCase{
- Title: "tc-in-commented-folder",
+ Title: "tc-in-commented-folder",
FolderPath: []string{"my-folder"},
- Priority: "high",
+ Priority: "high",
})
require.NoError(t, err)
@@ -579,9 +579,9 @@ func TestAddFolderValidation(t *testing.T) {
t.Run("folder already has test cases", func(t *testing.T) {
qasCSV := NewQASphereCSV()
err := qasCSV.AddTestCase(TestCase{
- Title: "tc",
+ Title: "tc",
FolderPath: []string{"root"},
- Priority: "high",
+ Priority: "high",
})
require.NoError(t, err)
err = qasCSV.AddFolder(Folder{FolderPath: []string{"root"}, Comment: "comment"})
From 517bf598c8183213cc7500b15c2bde0220d3082d Mon Sep 17 00:00:00 2001
From: Andrian Budantsov
Date: Fri, 13 Feb 2026 20:20:15 +0400
Subject: [PATCH 5/5] Add build-examples step to Makefile and CI
---
.github/workflows/ci.yml | 3 +++
Makefile | 5 ++++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b220f4b..ec9eac5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,5 +41,8 @@ jobs:
version: v1.62.2
args: --verbose --timeout=3m
+ - name: Build examples
+ run: make build-examples
+
- name: Test
run: make test
diff --git a/Makefile b/Makefile
index 1555ea4..b9e1ee1 100644
--- a/Makefile
+++ b/Makefile
@@ -12,4 +12,7 @@ lint: linters-install
test:
$(GOCMD) test -v -cover -race ./...
-.PHONY: test lint linters-install
+build-examples:
+ $(GOCMD) build ./examples/...
+
+.PHONY: test lint linters-install build-examples