feat(graph): intra-stage DAG sub-columns and connectors behind chips
When jobs within the same declared GitLab stage have needs: relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage deps are in sub-column 0, jobs that depend on them shift one column right. A new computeColumns() function handles the topo-sort; a narrower subStageGap (20 px vs 50 px stageGap) separates sub-columns; stage headers span all sub-columns. SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now emitted before job chip rectangles so connectors visually pass behind chips rather than on top of them. 100% statement coverage maintained (99 tests in graph package). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
This project uses [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [0.2.26] - 2026-06-25
|
||||
|
||||
### Changed
|
||||
|
||||
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
|
||||
|
||||
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
|
||||
|
||||
## [0.2.25] - 2026-06-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.25-blue.svg" alt="Release"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.26-blue.svg" alt="Release"></a>
|
||||
</p>
|
||||
|
||||
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
||||
|
||||
+3
-1
@@ -77,6 +77,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
||||
- ~~**Blocked / skipped state colouring**~~ — ✓ shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
|
||||
- ~~**Interactive HTML output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
|
||||
- ~~**Mermaid pipeline output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
|
||||
- ~~**Same-stage job ordering**~~ — ✓ shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
|
||||
- ~~**Graph links rendered behind job chips**~~ — ✓ shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
|
||||
|
||||
---
|
||||
|
||||
@@ -113,7 +115,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
||||
|
||||
## Reliability and developer experience
|
||||
|
||||
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25
|
||||
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25–v0.2.26
|
||||
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
||||
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
||||
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
||||
|
||||
+237
-109
@@ -16,21 +16,124 @@ import (
|
||||
|
||||
// Layout constants (pixels) – tuned to resemble GitLab's full pipeline graph view.
|
||||
const (
|
||||
chipW = 178 // job chip width
|
||||
chipH = 34 // job chip height
|
||||
chipGap = 6 // vertical gap between chips in a column
|
||||
iconCX = 14 // status circle: x offset from chip left edge to circle centre
|
||||
iconR = 7 // status circle radius (14 px diameter)
|
||||
textLeft = 27 // job name text: x offset from chip left edge
|
||||
labelH = 30 // stage-name label area height (text + bottom gap)
|
||||
topPad = 40 // outer top padding
|
||||
sidePad = 40 // outer left / right padding
|
||||
stageGap = 50 // horizontal gap between stage columns (connector space)
|
||||
botPad = 48 // outer bottom padding (legend lives here)
|
||||
chipW = 178 // job chip width
|
||||
chipH = 34 // job chip height
|
||||
chipGap = 6 // vertical gap between chips in a column
|
||||
iconCX = 14 // status circle: x offset from chip left edge to centre
|
||||
iconR = 7 // status circle radius (14 px diameter)
|
||||
textLeft = 27 // job name text: x offset from chip left edge
|
||||
labelH = 30 // stage-name label area height (text + bottom gap)
|
||||
topPad = 40 // outer top padding
|
||||
sidePad = 40 // outer left / right padding
|
||||
stageGap = 50 // horizontal gap between stage columns (connector space)
|
||||
subStageGap = 20 // horizontal gap between sub-columns within the same stage
|
||||
botPad = 48 // outer bottom padding (legend lives here)
|
||||
)
|
||||
|
||||
type svgPt struct{ x, y int }
|
||||
|
||||
// column represents one vertical stack of job chips in the SVG.
|
||||
// A declared GitLab stage maps to one column unless jobs within it have
|
||||
// same-stage needs:, in which case it splits into topological sub-columns.
|
||||
type column struct {
|
||||
stage string // declared GitLab stage name
|
||||
jobs []string // job names in this column, topologically ordered
|
||||
}
|
||||
|
||||
// computeColumns assigns jobs to SVG columns.
|
||||
// When jobs within the same declared stage have needs: relationships between
|
||||
// each other, the stage is split into topological sub-columns so that
|
||||
// depended-upon jobs appear to the left of the jobs that depend on them.
|
||||
func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
|
||||
var cols []column
|
||||
for _, stage := range stages {
|
||||
stageJobs := byStage[stage]
|
||||
sort.Strings(stageJobs)
|
||||
if len(stageJobs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Index same-stage membership.
|
||||
stageSet := make(map[string]bool, len(stageJobs))
|
||||
for _, j := range stageJobs {
|
||||
stageSet[j] = true
|
||||
}
|
||||
|
||||
// Check whether any job has a same-stage need.
|
||||
hasIntraNeeds := false
|
||||
outer:
|
||||
for _, j := range stageJobs {
|
||||
for _, need := range jobs[j].Needs {
|
||||
if dep := needsJobName(need); dep != "" && stageSet[dep] {
|
||||
hasIntraNeeds = true
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasIntraNeeds {
|
||||
cols = append(cols, column{stage: stage, jobs: stageJobs})
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute topological depth within the stage.
|
||||
depth := make(map[string]int, len(stageJobs))
|
||||
for _, j := range stageJobs {
|
||||
depth[j] = -1
|
||||
}
|
||||
inProgress := make(map[string]bool, len(stageJobs))
|
||||
var computeDepth func(j string) int
|
||||
computeDepth = func(j string) int {
|
||||
if depth[j] >= 0 {
|
||||
return depth[j]
|
||||
}
|
||||
if inProgress[j] {
|
||||
return 0 // cycle guard (cycles already rejected by GL029)
|
||||
}
|
||||
inProgress[j] = true
|
||||
maxDep := -1
|
||||
for _, need := range jobs[j].Needs {
|
||||
dep := needsJobName(need)
|
||||
if dep == "" || !stageSet[dep] {
|
||||
continue
|
||||
}
|
||||
d := computeDepth(dep)
|
||||
if d > maxDep {
|
||||
maxDep = d
|
||||
}
|
||||
}
|
||||
depth[j] = maxDep + 1
|
||||
return depth[j]
|
||||
}
|
||||
for _, j := range stageJobs {
|
||||
if depth[j] < 0 {
|
||||
computeDepth(j)
|
||||
}
|
||||
}
|
||||
|
||||
// One sub-column per topological depth level.
|
||||
maxDepth := 0
|
||||
for _, j := range stageJobs {
|
||||
if depth[j] > maxDepth {
|
||||
maxDepth = depth[j]
|
||||
}
|
||||
}
|
||||
for l := 0; l <= maxDepth; l++ {
|
||||
var levelJobs []string
|
||||
for _, j := range stageJobs {
|
||||
if depth[j] == l {
|
||||
levelJobs = append(levelJobs, j)
|
||||
}
|
||||
}
|
||||
if len(levelJobs) > 0 {
|
||||
sort.Strings(levelJobs)
|
||||
cols = append(cols, column{stage: stage, jobs: levelJobs})
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
|
||||
// pipeline layout and returns the path to the generated file.
|
||||
func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
|
||||
@@ -148,28 +251,42 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute per-stage column heights and job anchor points for connectors.
|
||||
// ── Column layout ─────────────────────────────────────────────────────────
|
||||
// Split stages into sub-columns when intra-stage needs exist.
|
||||
cols := computeColumns(stages, byStage, p.Jobs)
|
||||
|
||||
// X position of each column's left edge.
|
||||
colX := make([]int, len(cols))
|
||||
if len(cols) > 0 {
|
||||
colX[0] = sidePad
|
||||
for i := 1; i < len(cols); i++ {
|
||||
gap := stageGap
|
||||
if cols[i].stage == cols[i-1].stage {
|
||||
gap = subStageGap
|
||||
}
|
||||
colX[i] = colX[i-1] + chipW + gap
|
||||
}
|
||||
}
|
||||
|
||||
// Compute SVG dimensions and per-job connector anchor points.
|
||||
maxColH := 0
|
||||
rightMid := make(map[string]svgPt)
|
||||
leftMid := make(map[string]svgPt)
|
||||
|
||||
for i, stage := range stages {
|
||||
jobs := byStage[stage]
|
||||
sort.Strings(jobs)
|
||||
n := len(jobs)
|
||||
for ci, col := range cols {
|
||||
n := len(col.jobs)
|
||||
h := n*chipH + max(0, n-1)*chipGap
|
||||
if h > maxColH {
|
||||
maxColH = h
|
||||
}
|
||||
cx := sidePad + i*(chipW+stageGap)
|
||||
for j, name := range jobs {
|
||||
for j, name := range col.jobs {
|
||||
chy := topPad + labelH + j*(chipH+chipGap)
|
||||
rightMid[name] = svgPt{cx + chipW, chy + chipH/2}
|
||||
leftMid[name] = svgPt{cx, chy + chipH/2}
|
||||
rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
|
||||
leftMid[name] = svgPt{colX[ci], chy + chipH/2}
|
||||
}
|
||||
}
|
||||
|
||||
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap
|
||||
svgW := colX[len(cols)-1] + chipW + sidePad
|
||||
svgH := topPad + labelH + maxColH + botPad
|
||||
|
||||
// DAG mode: any visible job with a needs: list triggers job-to-job arrows.
|
||||
@@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
|
||||
// White page background.
|
||||
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
|
||||
|
||||
// ── Stage columns ─────────────────────────────────────────────────────────
|
||||
for i, stage := range stages {
|
||||
cx := sidePad + i*(chipW+stageGap)
|
||||
jobs := byStage[stage]
|
||||
sort.Strings(jobs)
|
||||
// ── Stage headers ─────────────────────────────────────────────────────────
|
||||
// Each declared stage may span multiple sub-columns; draw one header per stage.
|
||||
drawnHeader := make(map[string]bool)
|
||||
for ci, col := range cols {
|
||||
if drawnHeader[col.stage] {
|
||||
continue
|
||||
}
|
||||
// Span all sub-columns that belong to this stage.
|
||||
x1 := colX[ci]
|
||||
x2 := colX[ci] + chipW
|
||||
for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
|
||||
x2 = colX[j] + chipW
|
||||
}
|
||||
centerX := (x1 + x2) / 2
|
||||
|
||||
// Stage name – small, gray, uppercase, centered above the chip stack.
|
||||
// Stage name – small, gray, uppercase.
|
||||
wf(` <text x="%d" y="%d" text-anchor="middle" `+
|
||||
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
|
||||
`font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`,
|
||||
cx+chipW/2, topPad+13, svgEsc(strings.ToUpper(stage)))
|
||||
centerX, topPad+13, svgEsc(strings.ToUpper(col.stage)))
|
||||
|
||||
// Subtle separator line below stage name.
|
||||
// Separator line spanning all sub-columns.
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`,
|
||||
cx, topPad+21, cx+chipW, topPad+21)
|
||||
x1, topPad+21, x2, topPad+21)
|
||||
|
||||
// Job chips – each wrapped in a <g> with tooltip metadata.
|
||||
for j, name := range jobs {
|
||||
drawnHeader[col.stage] = true
|
||||
}
|
||||
|
||||
// ── Connectors (drawn before chips so they appear behind job blocks) ───────
|
||||
const connStroke = "#dbdbdb"
|
||||
|
||||
if dagMode {
|
||||
// Job-to-job bezier curves from needs:.
|
||||
for _, name := range visible {
|
||||
for _, need := range p.Jobs[name].Needs {
|
||||
dep := needsJobName(need)
|
||||
if dep == "" {
|
||||
continue
|
||||
}
|
||||
src, okS := rightMid[dep]
|
||||
dst, okD := leftMid[name]
|
||||
if !okS || !okD {
|
||||
continue
|
||||
}
|
||||
cpX := (src.x + dst.x) / 2
|
||||
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
||||
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
||||
// Small right-pointing triangle arrowhead.
|
||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Classic: bus-bar connectors between adjacent stage columns.
|
||||
// Every job in stage[i] fans to a vertical bus at the midpoint gap,
|
||||
// then fans out to every job in stage[i+1].
|
||||
for ci := 0; ci < len(cols)-1; ci++ {
|
||||
x1 := colX[ci] + chipW // right edge of current column
|
||||
x2 := colX[ci+1] // left edge of next column
|
||||
midX := (x1 + x2) / 2
|
||||
|
||||
srcJobs := cols[ci].jobs
|
||||
dstJobs := cols[ci+1].jobs
|
||||
|
||||
// Collect all Y midpoints to span the vertical bus bar.
|
||||
var allYs []int
|
||||
srcY := make([]int, len(srcJobs))
|
||||
dstY := make([]int, len(dstJobs))
|
||||
for j, name := range srcJobs {
|
||||
srcY[j] = rightMid[name].y
|
||||
allYs = append(allYs, srcY[j])
|
||||
}
|
||||
for j, name := range dstJobs {
|
||||
dstY[j] = leftMid[name].y
|
||||
allYs = append(allYs, dstY[j])
|
||||
}
|
||||
sort.Ints(allYs)
|
||||
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
|
||||
|
||||
// Vertical bus bar at midX (only when there are multiple Y levels).
|
||||
if busMinY < busMaxY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
midX, busMinY, midX, busMaxY, connStroke)
|
||||
}
|
||||
|
||||
// Horizontal stubs from each source job to the bus.
|
||||
for _, y := range srcY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
x1, y, midX, y, connStroke)
|
||||
}
|
||||
|
||||
// Horizontal stubs from bus to each destination job, with arrowhead.
|
||||
for _, y := range dstY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
midX, y, x2-7, y, connStroke)
|
||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job chips (drawn after connectors so they appear on top) ─────────────
|
||||
for ci, col := range cols {
|
||||
for j, name := range col.jobs {
|
||||
job := p.Jobs[name]
|
||||
chy := topPad + labelH + j*(chipH+chipGap)
|
||||
isSkipped := skippedJobs[name]
|
||||
cx := colX[ci]
|
||||
|
||||
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
|
||||
desc := "stage: " + stage
|
||||
desc := "stage: " + col.stage
|
||||
if job.When != "" {
|
||||
desc += "\nwhen: " + job.When
|
||||
}
|
||||
@@ -285,82 +489,6 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connectors ────────────────────────────────────────────────────────────
|
||||
const connStroke = "#dbdbdb"
|
||||
|
||||
if dagMode {
|
||||
// Job-to-job bezier curves from needs:.
|
||||
for _, name := range visible {
|
||||
for _, need := range p.Jobs[name].Needs {
|
||||
dep := needsJobName(need)
|
||||
if dep == "" {
|
||||
continue
|
||||
}
|
||||
src, okS := rightMid[dep]
|
||||
dst, okD := leftMid[name]
|
||||
if !okS || !okD {
|
||||
continue
|
||||
}
|
||||
cpX := (src.x + dst.x) / 2
|
||||
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
||||
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
||||
// Small right-pointing triangle arrowhead.
|
||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Classic: bus-bar connectors – every job in stage[i] fans to a vertical
|
||||
// bus at the midpoint column gap, then fans out to every job in stage[i+1].
|
||||
// This gives each job pair its own visual connection instead of a single
|
||||
// center-to-center line that misses jobs at the top and bottom of columns.
|
||||
for i := 0; i < len(stages)-1; i++ {
|
||||
x1 := sidePad + i*(chipW+stageGap) + chipW // right edge of left column
|
||||
x2 := sidePad + (i+1)*(chipW+stageGap) // left edge of right column
|
||||
midX := (x1 + x2) / 2
|
||||
|
||||
srcJobs := byStage[stages[i]]
|
||||
sort.Strings(srcJobs)
|
||||
dstJobs := byStage[stages[i+1]]
|
||||
sort.Strings(dstJobs)
|
||||
|
||||
// Collect all Y midpoints to span the vertical bus bar.
|
||||
var allYs []int
|
||||
srcY := make([]int, len(srcJobs))
|
||||
dstY := make([]int, len(dstJobs))
|
||||
for j, name := range srcJobs {
|
||||
srcY[j] = rightMid[name].y
|
||||
allYs = append(allYs, srcY[j])
|
||||
}
|
||||
for j, name := range dstJobs {
|
||||
dstY[j] = leftMid[name].y
|
||||
allYs = append(allYs, dstY[j])
|
||||
}
|
||||
sort.Ints(allYs)
|
||||
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
|
||||
|
||||
// Vertical bus bar at midX (only drawn when there are multiple Y levels).
|
||||
if busMinY < busMaxY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
midX, busMinY, midX, busMaxY, connStroke)
|
||||
}
|
||||
|
||||
// Horizontal stubs from each source job to the bus.
|
||||
for _, y := range srcY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
x1, y, midX, y, connStroke)
|
||||
}
|
||||
|
||||
// Horizontal stubs from bus to each destination job, with arrowhead.
|
||||
for _, y := range dstY {
|
||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||
midX, y, x2-7, y, connStroke)
|
||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────
|
||||
legend := []struct{ color, label string }{
|
||||
{"#1f75cb", "regular"},
|
||||
|
||||
@@ -520,6 +520,138 @@ func TestRenderHTML_WriteFileFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── computeColumns ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestComputeColumns_NoIntraNeeds(t *testing.T) {
|
||||
// No intra-stage needs → one column per stage.
|
||||
stages := []string{"build", "test"}
|
||||
byStage := map[string][]string{
|
||||
"build": {"build-a", "build-b"},
|
||||
"test": {"test-a"},
|
||||
}
|
||||
jobs := map[string]model.Job{
|
||||
"build-a": {Name: "build-a", Stage: "build"},
|
||||
"build-b": {Name: "build-b", Stage: "build"},
|
||||
"test-a": {Name: "test-a", Stage: "test"},
|
||||
}
|
||||
cols := computeColumns(stages, byStage, jobs)
|
||||
if len(cols) != 2 {
|
||||
t.Fatalf("expected 2 columns (one per stage), got %d", len(cols))
|
||||
}
|
||||
if cols[0].stage != "build" || len(cols[0].jobs) != 2 {
|
||||
t.Errorf("col[0]: got stage=%q jobs=%v", cols[0].stage, cols[0].jobs)
|
||||
}
|
||||
if cols[1].stage != "test" || len(cols[1].jobs) != 1 {
|
||||
t.Errorf("col[1]: got stage=%q jobs=%v", cols[1].stage, cols[1].jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColumns_IntraNeeds(t *testing.T) {
|
||||
// job-b depends on job-a in the same stage → split into 2 sub-columns.
|
||||
stages := []string{"build"}
|
||||
byStage := map[string][]string{
|
||||
"build": {"job-a", "job-b"},
|
||||
}
|
||||
jobs := map[string]model.Job{
|
||||
"job-a": {Name: "job-a", Stage: "build"},
|
||||
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
|
||||
}
|
||||
cols := computeColumns(stages, byStage, jobs)
|
||||
if len(cols) != 2 {
|
||||
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
|
||||
}
|
||||
// Both belong to the same stage.
|
||||
if cols[0].stage != "build" || cols[1].stage != "build" {
|
||||
t.Error("both sub-columns should be in stage 'build'")
|
||||
}
|
||||
// job-a has no same-stage deps → depth 0 → first sub-column.
|
||||
if len(cols[0].jobs) != 1 || cols[0].jobs[0] != "job-a" {
|
||||
t.Errorf("col[0] should contain job-a, got %v", cols[0].jobs)
|
||||
}
|
||||
// job-b depends on job-a → depth 1 → second sub-column.
|
||||
if len(cols[1].jobs) != 1 || cols[1].jobs[0] != "job-b" {
|
||||
t.Errorf("col[1] should contain job-b, got %v", cols[1].jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColumns_EmptyStageSkipped(t *testing.T) {
|
||||
// Stages with no jobs are silently skipped.
|
||||
stages := []string{"build", "empty", "test"}
|
||||
byStage := map[string][]string{
|
||||
"build": {"j1"},
|
||||
"test": {"j2"},
|
||||
}
|
||||
jobs := map[string]model.Job{
|
||||
"j1": {Name: "j1", Stage: "build"},
|
||||
"j2": {Name: "j2", Stage: "test"},
|
||||
}
|
||||
cols := computeColumns(stages, byStage, jobs)
|
||||
if len(cols) != 2 {
|
||||
t.Fatalf("expected 2 columns (empty stage skipped), got %d", len(cols))
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColumns_CrossStageNeedIgnored(t *testing.T) {
|
||||
// job-b has needs: for both job-a (same stage) and prev-job (different stage).
|
||||
// The cross-stage need must be ignored when computing topological depth.
|
||||
stages := []string{"build"}
|
||||
byStage := map[string][]string{
|
||||
"build": {"job-a", "job-b"},
|
||||
}
|
||||
jobs := map[string]model.Job{
|
||||
"job-a": {Name: "job-a", Stage: "build"},
|
||||
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a", "prev-job"}},
|
||||
"prev-job": {Name: "prev-job", Stage: "prepare"},
|
||||
}
|
||||
cols := computeColumns(stages, byStage, jobs)
|
||||
// Still split into 2 sub-columns; the cross-stage need is ignored.
|
||||
if len(cols) != 2 {
|
||||
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
|
||||
}
|
||||
if cols[0].jobs[0] != "job-a" {
|
||||
t.Errorf("expected job-a in first sub-column, got %v", cols[0].jobs)
|
||||
}
|
||||
if cols[1].jobs[0] != "job-b" {
|
||||
t.Errorf("expected job-b in second sub-column, got %v", cols[1].jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColumns_CycleGuard(t *testing.T) {
|
||||
// Intra-stage cycle must not hang (cycle guard returns depth 0).
|
||||
stages := []string{"build"}
|
||||
byStage := map[string][]string{
|
||||
"build": {"a", "b"},
|
||||
}
|
||||
jobs := map[string]model.Job{
|
||||
"a": {Name: "a", Stage: "build", Needs: []any{"b"}},
|
||||
"b": {Name: "b", Stage: "build", Needs: []any{"a"}},
|
||||
}
|
||||
cols := computeColumns(stages, byStage, jobs)
|
||||
// Should return without hanging; exact column count is implementation-defined.
|
||||
if len(cols) == 0 {
|
||||
t.Error("expected at least one column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineSVG_IntraStageDAG(t *testing.T) {
|
||||
// job-b depends on job-a in the same stage → SVG should render both,
|
||||
// placed as sub-columns (sub-stage gap between them).
|
||||
p := &model.Pipeline{
|
||||
Stages: []string{"build"},
|
||||
Jobs: map[string]model.Job{
|
||||
"job-a": {Name: "job-a", Stage: "build"},
|
||||
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
|
||||
},
|
||||
}
|
||||
svg := pipelineSVG(p, nil)
|
||||
if !strings.Contains(svg, "job-a") { t.Error("expected job-a in SVG") }
|
||||
if !strings.Contains(svg, "job-b") { t.Error("expected job-b in SVG") }
|
||||
// DAG mode: bezier connectors between the two jobs.
|
||||
if !strings.Contains(svg, "<path") {
|
||||
t.Error("expected bezier <path> connector between intra-stage jobs")
|
||||
}
|
||||
}
|
||||
|
||||
// ── htmlPage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHtmlPage(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user