0e51844c3a
- when: on_failure visual distinction: red circle (#d9534f), X-mark icon, dashed chip border, and Mermaid classDef; legend entry added - Job tooltip / detail panel: each chip wrapped in <g data-job="…"><title>…</title> <desc>…</desc> so SVG viewers and the HTML sidebar show stage, when, image, needs - Multi-job connector accuracy: classic mode now uses a bus-bar pattern (vertical rail at midpoint + per-job stubs) instead of one center-to-center line per stage pair - Blocked/skipped state colouring: RenderPipeline and pipelineSVG accept *cicontext.Context; skipped jobs rendered in grey (#868686) with dimmed text - Interactive HTML output (--format html): self-contained .html with inline SVG, mouse-wheel zoom, drag-to-pan, double-click reset, and a click-to-open sidebar - Mermaid pipeline output (--format mermaid): prints existing Pipeline() flowchart to stdout; suitable for mermaid.live or Markdown embedding - imageString() helper to extract image name from string or map form - 100% statement coverage maintained; 809 tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
134 lines
4.6 KiB
Go
134 lines
4.6 KiB
Go
package graph
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.k3nny.fr/glint/internal/model"
|
|
)
|
|
|
|
func TestPipeline_EmptyJobs(t *testing.T) {
|
|
p := &model.Pipeline{}
|
|
out := Pipeline(p)
|
|
if !strings.Contains(out, "no jobs defined") {
|
|
t.Errorf("expected 'no jobs defined', got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestPipeline_BasicTwoStages(t *testing.T) {
|
|
p := &model.Pipeline{
|
|
Stages: []string{"build", "test"},
|
|
Jobs: map[string]model.Job{
|
|
"build-job": {Name: "build-job", Stage: "build"},
|
|
"test-job": {Name: "test-job", Stage: "test"},
|
|
".tmpl": {Name: ".tmpl", Stage: "build"}, // hidden — excluded
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
if !strings.Contains(out, "build-job") { t.Error("expected build-job") }
|
|
if !strings.Contains(out, "test-job") { t.Error("expected test-job") }
|
|
if strings.Contains(out, ".tmpl") { t.Error(".tmpl should be excluded") }
|
|
// Classic mode: stage → stage connector
|
|
if !strings.Contains(out, "Classic") { t.Error("expected classic mode comment") }
|
|
}
|
|
|
|
func TestPipeline_DAGMode(t *testing.T) {
|
|
p := &model.Pipeline{
|
|
Stages: []string{"build", "test"},
|
|
Jobs: map[string]model.Job{
|
|
"build-job": {Name: "build-job", Stage: "build"},
|
|
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
|
|
}
|
|
|
|
func TestPipeline_DAGMode_CrossPipelineNeed(t *testing.T) {
|
|
p := &model.Pipeline{
|
|
Stages: []string{"build"},
|
|
Jobs: map[string]model.Job{
|
|
"build-job": {Name: "build-job", Stage: "build",
|
|
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
// cross-pipeline need should not add an arrow (job not in local map)
|
|
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
|
|
}
|
|
|
|
func TestPipeline_JobNoStage(t *testing.T) {
|
|
p := &model.Pipeline{
|
|
Jobs: map[string]model.Job{
|
|
"myjob": {Name: "myjob"},
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
if !strings.Contains(out, "myjob") { t.Error("expected myjob") }
|
|
}
|
|
|
|
func TestPipeline_SingleStage(t *testing.T) {
|
|
// Single stage → no connector
|
|
p := &model.Pipeline{
|
|
Stages: []string{"build"},
|
|
Jobs: map[string]model.Job{
|
|
"a": {Name: "a", Stage: "build"},
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
if strings.Contains(out, "-->") { t.Error("single stage should not have connectors") }
|
|
}
|
|
|
|
// ── stageID / sanitizeID ──────────────────────────────────────────────────────
|
|
|
|
func TestStageID(t *testing.T) {
|
|
if stageID("build") != "stage_build" { t.Error("plain name") }
|
|
if stageID("my-stage") != "stage_my_stage" { t.Error("hyphens replaced") }
|
|
}
|
|
|
|
func TestSanitizeID(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"build", "build"},
|
|
{"my-stage", "my_stage"},
|
|
{"with space", "with_space"},
|
|
{"ABC_123", "ABC_123"},
|
|
}
|
|
for _, tc := range cases {
|
|
if sanitizeID(tc.in) != tc.want {
|
|
t.Errorf("sanitizeID(%q)=%q want %q", tc.in, sanitizeID(tc.in), tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── jobClass ──────────────────────────────────────────────────────────────────
|
|
|
|
func TestJobClass(t *testing.T) {
|
|
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
|
|
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
|
|
if jobClass(model.Job{When: "on_failure"}) != "on_failure" { t.Error("on_failure") }
|
|
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
|
|
if jobClass(model.Job{}) != "regular" { t.Error("regular") }
|
|
}
|
|
|
|
func TestPipeline_OnFailureClass(t *testing.T) {
|
|
p := &model.Pipeline{
|
|
Stages: []string{"cleanup"},
|
|
Jobs: map[string]model.Job{
|
|
"cleanup-job": {Name: "cleanup-job", Stage: "cleanup", When: "on_failure"},
|
|
},
|
|
}
|
|
out := Pipeline(p)
|
|
if !strings.Contains(out, "on_failure") { t.Error("expected on_failure class") }
|
|
if !strings.Contains(out, "#d9534f") { t.Error("expected on_failure color in classDef") }
|
|
}
|
|
|
|
// ── needsJobName ──────────────────────────────────────────────────────────────
|
|
|
|
func TestNeedsJobName(t *testing.T) {
|
|
if needsJobName("job-a") != "job-a" { t.Error("string form") }
|
|
if needsJobName(map[string]any{"job": "job-b"}) != "job-b" { t.Error("map form") }
|
|
if needsJobName(map[string]any{"pipeline": "other", "job": "j"}) != "" { t.Error("cross-pipeline should return empty") }
|
|
if needsJobName(map[string]any{"no-job": true}) != "" { t.Error("map without job key") }
|
|
if needsJobName(42) != "" { t.Error("unexpected type") }
|
|
}
|