- 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
134 lines
4.0 KiB
Go
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")
|
|
}
|
|
}
|