Files
projax/internal/graph/layout_test.go
mAi 3901a1888e feat(phase 3f graph): visual /graph view, server-rendered SVG, layered DAG
- internal/graph package: pure-Go layered top-down DAG layout
  - LayerByLongestPath (multi-parent sits at max(parent-layer)+1)
  - OrderInLayer (slug-sort, deterministic)
  - Compute returns positions + edges + canvas size
  - cycle-safe (depth-cap)
- web/graph.go handler: filter chips reused from tree_filter
  - dim mode default (opacity 0.15 on non-matches)
  - ?isolate=1 hides non-matches + prunes orphaned edges
  - ?download=svg serves raw SVG attachment
- graph_svg.tmpl renders inline SVG: border colour by management
  (mai blue / self green / external orange / mixed dashed purple),
  opacity by status, tag pills, ×N multi-parent badge, click-navigate
- nav adds "graph" link; design.md §"Graph view" documents the surface
- 4 integration tests cover render, dim, isolate, SVG download
- 6 layout unit tests cover layering, ordering, cycle-guard
2026-05-15 19:06:57 +02:00

134 lines
4.0 KiB
Go

package graph
import (
"testing"
)
func TestLayerByLongestPathRootsAndChildren(t *testing.T) {
// Simple 2-layer tree:
// work, dev (roots)
// foo (under dev), bar (under work)
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "foo", Slug: "foo", ParentIDs: []string{"d"}},
{ID: "bar", Slug: "bar", ParentIDs: []string{"w"}},
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
t.Fatalf("LayerByLongestPath: %v", err)
}
for id, want := range map[string]int{"w": 0, "d": 0, "foo": 1, "bar": 1} {
if layers[id] != want {
t.Errorf("layer[%s] = %d, want %d", id, layers[id], want)
}
}
}
// Multi-parent: paliad under both work and dev. Its layer must be 1.
func TestLayerByLongestPathMultiParent(t *testing.T) {
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "p", Slug: "paliad", ParentIDs: []string{"w", "d"}},
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
t.Fatalf("LayerByLongestPath: %v", err)
}
if layers["p"] != 1 {
t.Errorf("multi-parent paliad layer = %d, want 1", layers["p"])
}
}
// A grandchild whose grandparent is two hops up should land at layer 2 —
// LongestPath, not shortest path.
func TestLayerByLongestPathRespectsLongerPath(t *testing.T) {
// Graph:
// r0 (root)
// ├── a
// │ └── x (depth-2 via a)
// └── x (also direct child via r0 — would be depth-1)
// LongestPath → x should be at layer 2.
nodes := []Node{
{ID: "r0", Slug: "r0"},
{ID: "a", Slug: "a", ParentIDs: []string{"r0"}},
{ID: "x", Slug: "x", ParentIDs: []string{"r0", "a"}},
}
layers, _ := LayerByLongestPath(nodes)
if layers["x"] != 2 {
t.Errorf("longest-path: x = %d, want 2", layers["x"])
}
}
// OrderInLayer must sort by slug for deterministic rendering.
func TestOrderInLayerSortsBySlug(t *testing.T) {
nodes := []Node{
{ID: "1", Slug: "bravo"},
{ID: "2", Slug: "alpha"},
{ID: "3", Slug: "charlie"},
}
layers := map[string]int{"1": 0, "2": 0, "3": 0}
out := OrderInLayer(nodes, layers)
if len(out) != 1 || len(out[0]) != 3 {
t.Fatalf("OrderInLayer output shape unexpected: %v", out)
}
want := []string{"2", "1", "3"} // alpha, bravo, charlie
for i, id := range want {
if out[0][i] != id {
t.Errorf("layer[0][%d] = %s, want %s (slug order)", i, out[0][i], id)
}
}
}
func TestComputeProducesEdgesAndPositions(t *testing.T) {
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "p", Slug: "paliad", ParentIDs: []string{"w", "d"}},
}
out, err := Compute(nodes, Opts{})
if err != nil {
t.Fatalf("Compute: %v", err)
}
if len(out.Positions) != 3 {
t.Errorf("expected 3 positions, got %d", len(out.Positions))
}
if len(out.Edges) != 2 {
t.Errorf("expected 2 edges (paliad↔work + paliad↔dev), got %d", len(out.Edges))
}
// Paliad sits below both work and dev → its Y should exceed both parents'.
pp := out.Positions["p"]
if !(pp.Y > out.Positions["w"].Y && pp.Y > out.Positions["d"].Y) {
t.Errorf("paliad Y=%.1f, expected below work=%.1f and dev=%.1f", pp.Y, out.Positions["w"].Y, out.Positions["d"].Y)
}
// Edges should connect parent center-bottom to child center-top.
for _, e := range out.Edges {
if e.SourceY >= e.TargetY {
t.Errorf("edge %s→%s: source Y=%.1f should be above target Y=%.1f", e.ParentID, e.ChildID, e.SourceY, e.TargetY)
}
}
}
func TestComputeEmptyInputDoesNotPanic(t *testing.T) {
out, err := Compute(nil, Opts{})
if err != nil {
t.Fatalf("Compute(nil): %v", err)
}
if out == nil || len(out.Positions) != 0 {
t.Fatalf("expected empty layout, got %+v", out)
}
}
// Cycle guard: depth-cap returns error rather than blowing the stack.
func TestLayerByLongestPathCycleErrors(t *testing.T) {
// a -> b -> a (cycle). LayerByLongestPath should bail out cleanly.
nodes := []Node{
{ID: "a", Slug: "a", ParentIDs: []string{"b"}},
{ID: "b", Slug: "b", ParentIDs: []string{"a"}},
}
if _, err := LayerByLongestPath(nodes); err == nil {
t.Fatalf("expected error on cycle, got nil")
}
}