From 9c0c5ae26ac10ad04d4b81dae3c1f9a4ccf90752 Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Thu, 2 Jul 2026 23:23:41 +0800 Subject: [PATCH] feat: validate svglide svg artifacts --- internal/svglide/receipt.go | 153 +++++++ internal/svglide/validate.go | 440 ++++++++++++++++++ internal/svglide/validate_test.go | 711 ++++++++++++++++++++++++++++++ 3 files changed, 1304 insertions(+) create mode 100644 internal/svglide/receipt.go create mode 100644 internal/svglide/validate.go create mode 100644 internal/svglide/validate_test.go diff --git a/internal/svglide/receipt.go b/internal/svglide/receipt.go new file mode 100644 index 00000000..e54d2d12 --- /dev/null +++ b/internal/svglide/receipt.go @@ -0,0 +1,153 @@ +package svglide + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +type validationLintReceipt struct { + Status string `json:"status"` + Issues []ValidationIssue `json:"issues"` +} + +func writeValidationArtifacts(safeRoot string, report ValidationReport) error { + report = normalizeValidationReport(report) + lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json") + if err != nil { + return err + } + raw, err := json.MarshalIndent(validationLintReceipt{ + Status: validationReceiptStatus(report), + Issues: report.Issues, + }, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil { + return err + } + queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md") + if err != nil { + return err + } + return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644) +} + +func normalizeValidationReport(report ValidationReport) ValidationReport { + if report.Issues == nil { + report.Issues = []ValidationIssue{} + } + report.OK = len(report.Issues) == 0 + for i := range report.Issues { + report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path) + if report.Issues[i].Path == "" { + report.Issues[i].Path = "(deck)" + } + report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code) + if report.Issues[i].Code == "" { + report.Issues[i].Code = "svglide.validation" + } + report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity) + if report.Issues[i].Severity == "" { + report.Issues[i].Severity = "error" + } + } + return report +} + +func validationReceiptStatus(report ValidationReport) string { + if report.OK { + return "passed" + } + return "failed" +} + +func renderRepairQueue(report ValidationReport) string { + if report.OK { + return "No repair needed.\n" + } + var b bytes.Buffer + b.WriteString("# SVGlide Repair Queue\n\n") + for _, issue := range report.Issues { + fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message) + } + return b.String() +} + +func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) { + cleanRel := filepath.Clean(rel) + if cleanRel == "." { + return "", fmt.Errorf("run file path must not be root") + } + dirRel := filepath.Dir(cleanRel) + if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil { + return "", err + } + path, err := safeRunPath(safeRoot, cleanRel) + if err != nil { + return "", err + } + info, err := vfs.Lstat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return path, nil + } + return "", err + } + if info.Mode()&fs.ModeSymlink != 0 { + return "", fmt.Errorf("run file path %q must not be a symlink", rel) + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("run file path %q must be a regular file", rel) + } + return path, nil +} + +func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) { + path, err := safeRunPath(safeRoot, rel) + if err != nil { + return "", err + } + cleanRel := filepath.Clean(rel) + if cleanRel == "." { + return path, nil + } + parts := strings.Split(cleanRel, string(filepath.Separator)) + cur := safeRoot + for i, part := range parts { + if part == "" || part == "." { + continue + } + cur = filepath.Join(cur, part) + info, err := vfs.Lstat(cur) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + if err := vfs.Mkdir(cur, 0o755); err != nil { + info, err = vfs.Lstat(cur) + if err != nil { + return "", err + } + } else { + continue + } + } + if info.Mode()&fs.ModeSymlink != 0 { + return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...)) + } + if !info.IsDir() { + return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...)) + } + } + return path, nil +} diff --git a/internal/svglide/validate.go b/internal/svglide/validate.go new file mode 100644 index 00000000..f244ca5f --- /dev/null +++ b/internal/svglide/validate.go @@ -0,0 +1,440 @@ +package svglide + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "math" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/vfs" +) + +const slideNamespace = "https://slides.bytedance.com/ns" +const svgNamespace = "http://www.w3.org/2000/svg" +const xlinkNamespace = "http://www.w3.org/1999/xlink" + +type ValidationReport struct { + OK bool `json:"ok"` + Issues []ValidationIssue `json:"issues"` +} + +type ValidationIssue struct { + Path string `json:"path"` + Code string `json:"code,omitempty"` + Message string `json:"message"` + Severity string `json:"severity,omitempty"` +} + +type validationDeck struct { + Slides []validationDeckSlide `json:"slides"` +} + +type validationDeckSlide struct { + Path string `json:"path"` +} + +type svgViewBox struct { + Width float64 + Height float64 + Valid bool +} + +type svgLintElement struct { + Excluded bool + TextCandidate bool +} + +func ValidateRun(root string) (ValidationReport, error) { + safeRoot, run, err := readRun(root) + if err != nil { + return ValidationReport{}, err + } + + deckPath := strings.TrimSpace(run.Artifacts.Deck) + if deckPath == "" { + return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty")) + } + deckRaw, err := readRunRegularArtifact(safeRoot, deckPath) + if err != nil { + issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)} + return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err)) + } + var deck validationDeck + if err := json.Unmarshal(deckRaw, &deck); err != nil { + issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)} + return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err)) + } + if len(deck.Slides) == 0 { + issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)} + return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath)) + } + + report := ValidationReport{Issues: []ValidationIssue{}} + for _, slide := range deck.Slides { + slidePath := strings.TrimSpace(slide.Path) + if slidePath == "" { + report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"}) + continue + } + + raw, err := readRunRegularArtifact(safeRoot, slidePath) + if err != nil { + report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()}) + continue + } + report.Issues = append(report.Issues, lintSVG(slidePath, raw)...) + } + report = normalizeValidationReport(report) + + if err := writeValidationArtifacts(safeRoot, report); err != nil { + return report, err + } + return report, nil +} + +func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) { + report := ValidationReport{Issues: []ValidationIssue{issue}} + report = normalizeValidationReport(report) + if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil { + if err != nil { + return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr) + } + return report, writeErr + } + return report, nil +} + +func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) { + info, path, exists, err := lstatRunPath(safeRoot, rel) + if err != nil { + return nil, err + } + if !exists || !info.Mode().IsRegular() { + return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel) + } + raw, err := vfs.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read run path %q: %w", rel, err) + } + return raw, nil +} + +func lintSVG(path string, raw []byte) []ValidationIssue { + decoder := xml.NewDecoder(bytes.NewReader(raw)) + var issues []ValidationIssue + var rootSeen bool + var rootIsSVG bool + var hasSlideRole bool + var hasViewBox bool + var hasVisibleContent bool + var viewBox svgViewBox + var stack []svgLintElement + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}} + } + switch typed := token.(type) { + case xml.StartElement: + parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded + excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed) + ctx := svgLintElement{ + Excluded: excluded, + TextCandidate: elementIsTextCandidate(typed), + } + if !rootSeen { + rootSeen = true + rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace + hasSlideRole = hasRootSlideRole(typed) + viewBox, hasViewBox = rootViewBox(typed) + stack = append(stack, ctx) + continue + } + if elementCountsAsVisibleContent(typed, viewBox, excluded) { + hasVisibleContent = true + } + stack = append(stack, ctx) + case xml.CharData: + if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) { + hasVisibleContent = true + } + case xml.EndElement: + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + default: + continue + } + } + + if !rootSeen { + return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}} + } + if !rootIsSVG { + issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be "}) + } + if !hasSlideRole { + issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`}) + } + if !hasViewBox { + issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"}) + } else if !viewBox.Valid { + issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"}) + } + if rootIsSVG && !hasVisibleContent { + issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"}) + } + return issues +} + +func hasRootSlideRole(start xml.StartElement) bool { + for _, attr := range start.Attr { + if strings.TrimSpace(attr.Value) != "slide" { + continue + } + if attr.Name.Local == "role" && attr.Name.Space == slideNamespace { + return true + } + } + return false +} + +func rootViewBox(start xml.StartElement) (svgViewBox, bool) { + for _, attr := range start.Attr { + if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" { + continue + } + return parseViewBox(attr.Value), true + } + return svgViewBox{}, false +} + +func parseViewBox(value string) svgViewBox { + fields := strings.Fields(strings.ReplaceAll(value, ",", " ")) + if len(fields) != 4 { + return svgViewBox{} + } + values := make([]float64, 4) + for i, field := range fields { + parsed, err := strconv.ParseFloat(field, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return svgViewBox{} + } + values[i] = parsed + } + width := values[2] + height := values[3] + if width <= 0 || height <= 0 { + return svgViewBox{} + } + return svgViewBox{Width: width, Height: height, Valid: true} +} + +func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool { + if excluded { + return false + } + if start.Name.Space != svgNamespace { + return false + } + if hasSemanticMarker(start, "background", "placeholder") { + return false + } + switch start.Name.Local { + case "text", "tspan": + return false + case "foreignObject", "chart": + return true + case "image", "use": + return elementHasHref(start) + case "g": + return hasSemanticMarker(start, "chart", "shape") + case "path", "circle", "ellipse", "line", "polyline", "polygon": + return true + case "rect": + return !isBackgroundRect(start, viewBox) + default: + return hasSemanticMarker(start, "chart", "shape") + } +} + +func activeVisibleTextCandidate(stack []svgLintElement) bool { + for i := len(stack) - 1; i >= 0; i-- { + if stack[i].Excluded { + return false + } + if stack[i].TextCandidate { + return true + } + } + return false +} + +func elementIsTextCandidate(start xml.StartElement) bool { + return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan") +} + +func elementIsHidden(start xml.StartElement) bool { + for _, attr := range start.Attr { + if attr.Name.Space != "" { + continue + } + switch attr.Name.Local { + case "display": + if strings.EqualFold(strings.TrimSpace(attr.Value), "none") { + return true + } + case "visibility": + if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") { + return true + } + case "opacity": + if opacityIsZero(attr.Value) { + return true + } + case "style": + if styleHidesElement(attr.Value) { + return true + } + } + } + return false +} + +func styleHidesElement(style string) bool { + for _, declaration := range strings.Split(style, ";") { + name, value, ok := strings.Cut(declaration, ":") + if !ok { + continue + } + switch strings.ToLower(strings.TrimSpace(name)) { + case "display": + if strings.EqualFold(strings.TrimSpace(value), "none") { + return true + } + case "visibility": + if strings.EqualFold(strings.TrimSpace(value), "hidden") { + return true + } + case "opacity": + if opacityIsZero(value) { + return true + } + } + } + return false +} + +func opacityIsZero(value string) bool { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return false + } + return floatEqual(parsed, 0) +} + +func elementIsNonRendering(start xml.StartElement) bool { + if start.Name.Space != svgNamespace { + return false + } + switch start.Name.Local { + case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script": + return true + default: + return false + } +} + +func elementHasHref(start xml.StartElement) bool { + for _, attr := range start.Attr { + if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" { + continue + } + if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace { + return true + } + } + return false +} + +func hasSemanticMarker(start xml.StartElement, terms ...string) bool { + for _, attr := range start.Attr { + if attr.Name.Space != "" { + continue + } + name := strings.ToLower(attr.Name.Local) + if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") { + continue + } + value := strings.ToLower(attr.Value) + for _, term := range terms { + if strings.Contains(value, term) { + return true + } + } + } + return false +} + +func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool { + if hasSemanticMarker(start, "background", "placeholder") { + return true + } + width := attrValue(start, "width") + height := attrValue(start, "height") + if width == "100%" && height == "100%" { + return true + } + if !viewBox.Valid { + return false + } + x := attrFloatDefault(start, "x", 0) + y := attrFloatDefault(start, "y", 0) + w, okW := parseAttrFloat(width) + h, okH := parseAttrFloat(height) + if !okW || !okH { + return false + } + return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height) +} + +func attrValue(start xml.StartElement, name string) string { + for _, attr := range start.Attr { + if attr.Name.Space == "" && attr.Name.Local == name { + return strings.TrimSpace(attr.Value) + } + } + return "" +} + +func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 { + value := attrValue(start, name) + if value == "" { + return fallback + } + parsed, ok := parseAttrFloat(value) + if !ok { + return fallback + } + return parsed +} + +func parseAttrFloat(value string) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0, false + } + return parsed, true +} + +func floatEqual(a float64, b float64) bool { + return math.Abs(a-b) < 0.001 +} diff --git a/internal/svglide/validate_test.go b/internal/svglide/validate_test.go new file mode 100644 index 00000000..3ab026c6 --- /dev/null +++ b/internal/svglide/validate_test.go @@ -0,0 +1,711 @@ +package svglide + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG()) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + + if report.OK { + t.Fatalf("OK = true, want false") + } + if len(report.Issues) == 0 { + t.Fatal("expected background-only SVG issue") + } + if !validationIssuesContain(report.Issues, "background") { + t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues) + } + + raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json")) + if err != nil { + t.Fatalf("missing lint receipt: %v", err) + } + var receipt ValidationReport + if err := json.Unmarshal(raw, &receipt); err != nil { + t.Fatalf("lint receipt is not ValidationReport JSON: %v", err) + } + if receipt.OK || len(receipt.Issues) == 0 { + t.Fatalf("lint receipt = %+v, want failing issues", receipt) + } + var lintReceipt validationLintReceipt + if err := json.Unmarshal(raw, &lintReceipt); err != nil { + t.Fatalf("lint receipt is not schema-compatible JSON: %v", err) + } + if lintReceipt.Status != "failed" { + t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status) + } + if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" { + t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0]) + } + + queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md")) + if err != nil { + t.Fatalf("missing repair queue: %v", err) + } + if !strings.Contains(string(queue), "slides/01.svg") { + t.Fatalf("repair queue = %q, want slide path", string(queue)) + } +} + +func TestValidateRunPassesVisibleTextSVG(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG()) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + + if !report.OK { + t.Fatalf("OK = false, issues = %+v", report.Issues) + } + if len(report.Issues) != 0 { + t.Fatalf("Issues = %+v, want empty", report.Issues) + } + queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md")) + if err != nil { + t.Fatalf("missing repair queue: %v", err) + } + if strings.TrimSpace(string(queue)) != "No repair needed." { + t.Fatalf("repair queue = %q, want no repair text", string(queue)) + } +} + +func TestValidateRunRejectsEscapingSlidePath(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "../outside.svg") + writeValidateTestFile(t, "outside.svg", visibleTextSVG()) + + report, err := ValidateRun("demo") + if err == nil && report.OK { + t.Fatalf("ValidateRun OK with escaping slide path: %+v", report) + } +} + +func TestValidateRunRejectsSlideSymlinks(t *testing.T) { + tests := []struct { + name string + deckPath string + setupLink func(t *testing.T, outside string) + }{ + { + name: "file symlink", + deckPath: "slides/01.svg", + setupLink: func(t *testing.T, outside string) { + if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "intermediate symlink", + deckPath: "slides/link/01.svg", + setupLink: func(t *testing.T, outside string) { + if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil { + t.Fatal(err) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := initValidateTestRun(t) + writeMinimalDeck(t, "demo", tt.deckPath) + outside := filepath.Join(filepath.Dir(cwd), "outside") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil { + t.Fatal(err) + } + tt.setupLink(t, outside) + + report, err := ValidateRun("demo") + if err == nil && report.OK { + t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report) + } + }) + } +} + +func TestValidateRunRejectsDeckSymlinks(t *testing.T) { + tests := []struct { + name string + deckPath string + setupLink func(t *testing.T, outside string) + }{ + { + name: "file symlink", + deckPath: filepath.Join("demo", "outline", "deck.json"), + setupLink: func(t *testing.T, outside string) { + if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "intermediate symlink", + deckPath: filepath.Join("demo", "outline_link", "deck.json"), + setupLink: func(t *testing.T, outside string) { + run := readValidateTestRunFile(t) + run.Artifacts.Deck = "outline_link/deck.json" + writeValidateTestRunFile(t, run) + if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil { + t.Fatal(err) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cwd := initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG()) + outside := filepath.Join(filepath.Dir(cwd), "outside") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg") + tt.setupLink(t, outside) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report) + } + }) + } +} + +func TestValidateRunRejectsEmptyDeck(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo") + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + assertValidationFailureArtifacts(t, "demo", report, "no slides") +} + +func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + wantErr string + }{ + { + name: "missing deck", + setup: func(t *testing.T) { + if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil { + t.Fatal(err) + } + }, + wantErr: "deck", + }, + { + name: "invalid deck json", + setup: func(t *testing.T) { + writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`) + }, + wantErr: "deck", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + tt.setup(t) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + assertValidationFailureArtifacts(t, "demo", report, tt.wantErr) + }) + } +} + +func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) { + initValidateTestRun(t) + run := readValidateTestRunFile(t) + run.Artifacts.Deck = "custom/deck.json" + writeValidateTestRunFile(t, run) + writeMinimalDeck(t, "demo", "slides/bad.svg") + writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG()) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if !report.OK { + t.Fatalf("OK = false, issues = %+v", report.Issues) + } +} + +func TestValidateRunReportsInvalidXML(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `broken`) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + + if report.OK { + t.Fatalf("OK = true, want false") + } + if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") { + t.Fatalf("Issues = %+v, want XML parse issue", report.Issues) + } +} + +func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) { + tests := []struct { + name string + svg string + want string + }{ + { + name: "non svg root", + svg: `not svg`, + want: "", + }, + { + name: "wrong svg namespace", + svg: `hello`, + want: "", + }, + { + name: "missing slide role", + svg: `hello`, + want: `slide:role`, + }, + { + name: "missing viewBox", + svg: `hello`, + want: `viewBox`, + }, + { + name: "wrong namespaced slide role", + svg: `hello`, + want: `slide:role`, + }, + { + name: "unbound slide prefix role", + svg: `hello`, + want: `slide:role`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatalf("OK = true, want false") + } + if !validationIssuesContain(report.Issues, tt.want) { + t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want) + } + }) + } +} + +func TestValidateRunRejectsInvalidViewBox(t *testing.T) { + tests := []struct { + name string + svg string + }{ + { + name: "bad viewBox with text", + svg: `hello`, + }, + { + name: "bad viewBox origin fields", + svg: `hello`, + }, + { + name: "nan viewBox width", + svg: `hello`, + }, + { + name: "zero viewBox with text", + svg: `hello`, + }, + { + name: "bad viewBox with full page rect", + svg: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatalf("OK = true, want false") + } + if !validationIssuesContain(report.Issues, "viewBox") { + t.Fatalf("Issues = %+v, want viewBox issue", report.Issues) + } + }) + } +} + +func TestValidateRunIgnoresNonVisibleContent(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "text in defs", + body: `hidden template`, + }, + { + name: "display none text", + body: `hidden`, + }, + { + name: "visibility hidden text", + body: `hidden`, + }, + { + name: "style display none text", + body: `hidden`, + }, + { + name: "style visibility hidden text", + body: `hidden`, + }, + { + name: "opacity zero text", + body: `hidden`, + }, + { + name: "style opacity zero text", + body: `hidden`, + }, + { + name: "empty text", + body: ` `, + }, + { + name: "image without href", + body: ``, + }, + { + name: "use without href", + body: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + svg := `` + tt.body + `` + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatalf("OK = true, want false") + } + if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") { + t.Fatalf("Issues = %+v, want placeholder issue", report.Issues) + } + }) + } +} + +func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "wrong namespace path", + body: ``, + }, + { + name: "wrong namespace text", + body: `hidden by namespace`, + }, + { + name: "wrong namespace image href", + body: ``, + }, + { + name: "wrong namespace viewBox", + body: `hello`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + viewBox := `viewBox="0 0 960 540"` + if tt.name == "wrong namespace viewBox" { + viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"` + } + svg := `` + tt.body + `` + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatalf("OK = true, want false") + } + if tt.name == "wrong namespace viewBox" { + if !validationIssuesContain(report.Issues, "viewBox") { + t.Fatalf("Issues = %+v, want viewBox issue", report.Issues) + } + return + } + if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") { + t.Fatalf("Issues = %+v, want placeholder issue", report.Issues) + } + }) + } +} + +func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + svg := `` + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if !report.OK { + t.Fatalf("OK = false, issues = %+v", report.Issues) + } +} + +func TestValidateRunAcceptsPlainHref(t *testing.T) { + initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + svg := `` + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg) + + report, err := ValidateRun("demo") + if err != nil { + t.Fatal(err) + } + if !report.OK { + t.Fatalf("OK = false, issues = %+v", report.Issues) + } +} + +func TestValidateRunRejectsReceiptSymlink(t *testing.T) { + cwd := initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG()) + if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil { + t.Fatal(err) + } + outside := filepath.Join(filepath.Dir(cwd), "outside-receipts") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil { + t.Fatal(err) + } + + if _, err := ValidateRun("demo"); err == nil { + t.Fatal("expected receipt symlink write refusal") + } + if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) { + t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err) + } +} + +func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) { + cwd := initValidateTestRun(t) + writeMinimalDeck(t, "demo", "slides/01.svg") + writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG()) + if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json") + if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil { + t.Fatal(err) + } + + if _, err := ValidateRun("demo"); err == nil { + t.Fatal("expected lint receipt file symlink write refusal") + } + raw, err := os.ReadFile(outside) + if err != nil { + t.Fatal(err) + } + if string(raw) != "outside" { + t.Fatalf("outside file was overwritten: %q", string(raw)) + } +} + +func initValidateTestRun(t *testing.T) string { + t.Helper() + cwd := t.TempDir() + t.Chdir(cwd) + if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil { + t.Fatal(err) + } + if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil { + t.Fatal(err) + } + return cwd +} + +func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) { + t.Helper() + writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...) +} + +func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) { + t.Helper() + slides := make([]map[string]string, 0, len(slidePaths)) + for i, path := range slidePaths { + slides = append(slides, map[string]string{ + "id": "slide-" + string(rune('1'+i)), + "title": "Slide", + "summary": "Summary", + "role": "content", + "key_message": "Message", + "path": path, + }) + } + raw, err := json.MarshalIndent(map[string]any{ + "title": "Demo", + "slides": slides, + }, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + writeValidateTestFile(t, path, string(raw)) +} + +func readValidateTestRunFile(t *testing.T) Run { + t.Helper() + raw, err := os.ReadFile(filepath.Join("demo", "run.json")) + if err != nil { + t.Fatal(err) + } + var run Run + if err := json.Unmarshal(raw, &run); err != nil { + t.Fatal(err) + } + return run +} + +func writeValidateTestRunFile(t *testing.T, run Run) { + t.Helper() + raw, err := json.MarshalIndent(run, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func writeValidateTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func backgroundOnlySVG() string { + return `` +} + +func visibleTextSVG() string { + return `Hello` +} + +func validationIssuesContain(issues []ValidationIssue, needle string) bool { + for _, issue := range issues { + if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) { + return true + } + } + return false +} + +func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) { + t.Helper() + if report.OK { + t.Fatalf("OK = true, want false") + } + if len(report.Issues) == 0 { + t.Fatal("expected validation issue") + } + if !validationIssuesContain(report.Issues, needle) { + t.Fatalf("Issues = %+v, want %q", report.Issues, needle) + } + + raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json")) + if err != nil { + t.Fatalf("missing lint receipt: %v", err) + } + var receipt ValidationReport + if err := json.Unmarshal(raw, &receipt); err != nil { + t.Fatalf("lint receipt is not ValidationReport JSON: %v", err) + } + if receipt.OK || !validationIssuesContain(receipt.Issues, needle) { + t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle) + } + + queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md")) + if err != nil { + t.Fatalf("missing repair queue: %v", err) + } + if !strings.Contains(string(queue), needle) { + t.Fatalf("repair queue = %q, want %q", string(queue), needle) + } +}