Excalidraw scene now mirrors the v5 routing model: - Clamps export as 12×12 grey rounded squares (BackgroundColor=#888888, StrokeColor=#555555, Roundness type 3). Distinct from the red IO marker diamonds so wall outlets vs. routing anchors stay readable. Frame_id propagates into the element's FrameID per the existing pattern. - Cable arrows include clamp positions as mid-vertices in the `points` array. Pre-grouped + sort.Slice-sorted by ord; each mid-vertex is added as an (x-fromAnchor.x, y-fromAnchor.y) offset. startBinding / endBinding still point at the from / to endpoint excalidraw_ids; mid-vertices are unbound (Excalidraw doesn't have per-vertex binding). - IDAssignment grows a Clamps map; PersistExcalidrawIDs accepts it and updates clamps.excalidraw_id on first export so re-exports reuse the same element ids (collab cursors / undo history survive). - Bundle-stripe overlay is **viewer-only** — Excalidraw can't represent gradient strokes losslessly, so we export individual cable arrows and let the in-app viewer derive the bundle viz. Tests: - TestBuildScene_ClampsRenderAsRectangles — 2 clamps → 2 rectangle elements + 2 ids in IDAssignment.Clamps. - TestBuildScene_ArrowPointsIncludeClamps — cable with 1 clamp → arrow.Points has 3 entries; middle vertex equals the clamp's position relative to fromAnchor. This closes the v5 slice plan (§11.10). Six slices, one branch, one redeploy below.
226 lines
6.4 KiB
Go
226 lines
6.4 KiB
Go
package exporter
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"mgit.msbls.de/m/mcables/internal/db"
|
|
)
|
|
|
|
// deterministic id generator for tests
|
|
func newSeq() func() string {
|
|
i := 0
|
|
return func() string {
|
|
i++
|
|
return "id" + strings.Repeat("0", 19-len(itoa(i))) + itoa(i)
|
|
}
|
|
}
|
|
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
buf := [20]byte{}
|
|
pos := len(buf)
|
|
for i > 0 {
|
|
pos--
|
|
buf[pos] = byte('0' + i%10)
|
|
i /= 10
|
|
}
|
|
return string(buf[pos:])
|
|
}
|
|
|
|
func sampleSnapshot() *db.Snapshot {
|
|
pid := int64(1)
|
|
devID := int64(10)
|
|
devID2 := int64(11)
|
|
portID := int64(100)
|
|
portID2 := int64(101)
|
|
ioID := int64(200)
|
|
|
|
return &db.Snapshot{
|
|
Project: db.Project{ID: pid, Name: "LOFT", DrawingName: "LOFT.excalidraw"},
|
|
Frames: []db.Frame{
|
|
{ID: 1, ProjectID: pid, Name: "desk", X: 100, Y: 100, Width: 800, Height: 500},
|
|
},
|
|
Devices: []db.Device{
|
|
{ID: devID, ProjectID: pid, Name: "NAS", Color: "#1e1e1e", X: 200, Y: 200, Width: 100, Height: 35, FrameID: ptr(int64(1))},
|
|
{ID: devID2, ProjectID: pid, Name: "Switch", Color: "#1e1e1e", X: 400, Y: 200, Width: 100, Height: 35},
|
|
},
|
|
Ports: []db.Port{
|
|
{ID: portID, ProjectID: pid, DeviceID: devID, TypeID: 5, XOffset: 50, YOffset: 35},
|
|
{ID: portID2, ProjectID: pid, DeviceID: devID2, TypeID: 5, XOffset: 50, YOffset: 35},
|
|
},
|
|
IOMarkers: []db.IOMarker{
|
|
{ID: ioID, ProjectID: pid, Label: "Wall A", X: 50, Y: 50},
|
|
},
|
|
Cables: []db.Cable{
|
|
{ID: 1000, ProjectID: pid, TypeID: 5,
|
|
FromPortID: &portID, ToPortID: &portID2, Auto: false},
|
|
},
|
|
CableTypes: []db.CableType{
|
|
{ID: 1, Name: "Power", Color: "#e03131"},
|
|
{ID: 2, Name: "USB", Color: "#2f9e44"},
|
|
{ID: 3, Name: "HDMI", Color: "#1971c2"},
|
|
{ID: 4, Name: "DP", Color: "#9c36b5"},
|
|
{ID: 5, Name: "RJ45", Color: "#ffd500"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func TestBuildScene_BasicShape(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
|
|
|
if scene.Type != "excalidraw" || scene.Version != 2 {
|
|
t.Errorf("bad header: %+v", scene)
|
|
}
|
|
// frame(1) + device-rect+text(2 each) + ports(2) + io+text(2) +
|
|
// cable(1) + legend(5) = 1 + 4 + 2 + 2 + 1 + 5 = 15.
|
|
if len(scene.Elements) < 15 {
|
|
t.Errorf("element count = %d, want ≥15", len(scene.Elements))
|
|
}
|
|
if len(ids.Frames) != 1 || len(ids.Devices) != 2 || len(ids.Ports) != 2 ||
|
|
len(ids.IOMarkers) != 1 || len(ids.Cables) != 1 {
|
|
t.Errorf("id assignment shape wrong: %+v", ids)
|
|
}
|
|
}
|
|
|
|
func TestBuildScene_ReusesExistingExcalidrawIDs(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
// Pre-assign an excalidraw_id on the first device.
|
|
preset := "preset0000000000000NAS"[:21]
|
|
snap.Devices[0].ExcalidrawID = &preset
|
|
_, ids := BuildScene(snap, 1700000000000, newSeq())
|
|
if ids.Devices[snap.Devices[0].ID] != preset {
|
|
t.Errorf("preset id not reused: got %q, want %q", ids.Devices[snap.Devices[0].ID], preset)
|
|
}
|
|
}
|
|
|
|
func TestBuildScene_ArrowsBindToPorts(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
|
// The arrow's startBinding should reference the from-port's element id.
|
|
fromPortElID := ids.Ports[100]
|
|
toPortElID := ids.Ports[101]
|
|
var found *Element
|
|
for i := range scene.Elements {
|
|
if scene.Elements[i].Type == "arrow" {
|
|
found = &scene.Elements[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("no arrow in scene")
|
|
}
|
|
if found.StartBinding == nil || found.StartBinding.ElementID != fromPortElID {
|
|
t.Errorf("start binding wrong: %+v", found.StartBinding)
|
|
}
|
|
if found.EndBinding == nil || found.EndBinding.ElementID != toPortElID {
|
|
t.Errorf("end binding wrong: %+v", found.EndBinding)
|
|
}
|
|
}
|
|
|
|
func TestBuildScene_BundlesIgnored(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
// Snapshot.Bundles is unused in the exporter for v0 per design §4.1.
|
|
// Add some and confirm no bundle elements appear in the scene.
|
|
snap.Bundles = []db.Bundle{{ID: 1, Name: "trunk", CableIDs: []int64{1000}}}
|
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
|
for _, e := range scene.Elements {
|
|
if strings.Contains(e.Type, "bundle") {
|
|
t.Errorf("bundle element leaked into scene: %+v", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildScene_ClampsRenderAsRectangles(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
snap.Clamps = []db.Clamp{
|
|
{ID: 1, ProjectID: 1, X: 500, Y: 300},
|
|
{ID: 2, ProjectID: 1, X: 550, Y: 320},
|
|
}
|
|
scene, ids := BuildScene(snap, 1700000000000, newSeq())
|
|
if len(ids.Clamps) != 2 {
|
|
t.Errorf("clamp ids = %d, want 2", len(ids.Clamps))
|
|
}
|
|
clampElIDs := map[string]bool{}
|
|
for _, id := range ids.Clamps {
|
|
clampElIDs[id] = true
|
|
}
|
|
got := 0
|
|
for _, e := range scene.Elements {
|
|
if clampElIDs[e.ID] && e.Type == "rectangle" {
|
|
got++
|
|
}
|
|
}
|
|
if got != 2 {
|
|
t.Errorf("clamp rectangle elements = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildScene_ArrowPointsIncludeClamps(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
snap.Clamps = []db.Clamp{
|
|
{ID: 10, ProjectID: 1, X: 350, Y: 250},
|
|
}
|
|
snap.CableClamps = []db.CableClamp{
|
|
{CableID: 1000, ClampID: 10, Ord: 1},
|
|
}
|
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
|
var arrow *Element
|
|
for i := range scene.Elements {
|
|
if scene.Elements[i].Type == "arrow" {
|
|
arrow = &scene.Elements[i]
|
|
break
|
|
}
|
|
}
|
|
if arrow == nil {
|
|
t.Fatal("no arrow in scene")
|
|
}
|
|
if len(arrow.Points) != 3 {
|
|
t.Errorf("arrow points = %d, want 3 (from + clamp + to): %+v", len(arrow.Points), arrow.Points)
|
|
}
|
|
// First point is always (0, 0) by convention; middle point should
|
|
// equal the clamp's position relative to the arrow's anchor.
|
|
if arrow.Points[0][0] != 0 || arrow.Points[0][1] != 0 {
|
|
t.Errorf("first point = %v, want [0,0]", arrow.Points[0])
|
|
}
|
|
// Middle vertex = clamp.x - fromAnchor.x, clamp.y - fromAnchor.y.
|
|
// fromAnchor for port 100 = (200 + 50, 200 + 35) = (250, 235).
|
|
wantX, wantY := 350.0-250.0, 250.0-235.0
|
|
if arrow.Points[1][0] != wantX || arrow.Points[1][1] != wantY {
|
|
t.Errorf("mid point = %v, want [%v, %v]", arrow.Points[1], wantX, wantY)
|
|
}
|
|
}
|
|
|
|
func TestMarshalScene_IsJSON(t *testing.T) {
|
|
snap := sampleSnapshot()
|
|
scene, _ := BuildScene(snap, 1700000000000, newSeq())
|
|
b, err := MarshalScene(scene)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
var roundtrip map[string]any
|
|
if err := json.Unmarshal(b, &roundtrip); err != nil {
|
|
t.Fatalf("roundtrip: %v", err)
|
|
}
|
|
if roundtrip["type"] != "excalidraw" {
|
|
t.Errorf("type field = %v, want excalidraw", roundtrip["type"])
|
|
}
|
|
}
|
|
|
|
func TestGenerate21(t *testing.T) {
|
|
a := Generate21()
|
|
b := Generate21()
|
|
if len(a) != 21 || len(b) != 21 {
|
|
t.Errorf("len wrong: %d / %d", len(a), len(b))
|
|
}
|
|
if a == b {
|
|
t.Errorf("ids collide: %q == %q", a, b)
|
|
}
|
|
}
|