Before: ApplyTemplate dropped devices in a horizontal row at fixed canvas coords with frame_id NULL — devices appeared anywhere and m had no way to express "these belong together". Now: each apply creates a frame named after the template (suffixed "… 2/3/…" on name collision) and lays the devices out in a uniform grid inside it. Grid is roughly square (cols = ceil(sqrt(N)), capped at 4) with 30/50 px gaps and 32/48 px padding. Each device gets the new frame's id and grid-cell coords. Schema unchanged. ApplyTemplateResult.frames_added carries the new frame so the frontend can refresh the canvas without a full snapshot reload. Tests: - TestApplyTemplate_CreatesFrameAndPlacesDevicesInside — frame is created with the template's name, every device has frame_id set, every device sits inside the frame rect, no two devices share a grid cell. - TestApplyTemplate_FrameNameSuffixOnCollision — pre-existing "Living Room" frame in the project ⇒ template's frame named "Living Room 2". - Existing tests unchanged.
330 lines
11 KiB
Go
330 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// builtInTypeID returns the id of the named built-in device type.
|
|
func builtInTypeID(t *testing.T, s *Store, name string) int64 {
|
|
t.Helper()
|
|
all, _ := s.ListBuiltInDeviceTypes()
|
|
for _, dt := range all {
|
|
if dt.Name == name {
|
|
return dt.ID
|
|
}
|
|
}
|
|
t.Fatalf("built-in %q not found", name)
|
|
return 0
|
|
}
|
|
|
|
// ------------------------------------------------------ basic solver wins
|
|
|
|
func TestSolve_BasicNAStoSwitch(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
nasT := builtInTypeID(t, s, "NAS")
|
|
swT := builtInTypeID(t, s, "Switch")
|
|
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
rj45 := int64(5)
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
|
})
|
|
res, err := s.Solve(p.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("solve: %v", err)
|
|
}
|
|
if len(res.CablesAdded) != 1 {
|
|
t.Fatalf("cables_added len = %d, want 1", len(res.CablesAdded))
|
|
}
|
|
if res.CablesAdded[0].TypeID != rj45 {
|
|
t.Errorf("cable type = %d, want %d (RJ45)", res.CablesAdded[0].TypeID, rj45)
|
|
}
|
|
if !res.CablesAdded[0].Auto {
|
|
t.Errorf("cable.auto should be true")
|
|
}
|
|
if len(res.Unsatisfied) != 0 {
|
|
t.Errorf("unsatisfied should be empty; got %+v", res.Unsatisfied)
|
|
}
|
|
}
|
|
|
|
func TestSolve_AmbiguousType_RequirementUnsatisfied(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
// Both PCs have Power + USB + HDMI + RJ45 → multiple types match.
|
|
pcT := builtInTypeID(t, s, "PC")
|
|
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "A", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "B", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: a.ID, ToDeviceID: b.ID, // no PreferredCableTypeID
|
|
})
|
|
res, _ := s.Solve(p.ID, true)
|
|
if len(res.CablesAdded) != 0 {
|
|
t.Errorf("ambiguous: should not add cables, got %d", len(res.CablesAdded))
|
|
}
|
|
if len(res.Unsatisfied) != 1 || res.Unsatisfied[0].Reason == "" {
|
|
t.Errorf("expected 1 unsatisfied req with non-empty reason; got %+v", res.Unsatisfied)
|
|
}
|
|
}
|
|
|
|
func TestSolve_NoFreePort_RequirementUnsatisfied(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
// Mouse only has 1 USB port. Two USB requirements against it should
|
|
// leave one unsatisfied.
|
|
mouseT := builtInTypeID(t, s, "Mouse")
|
|
pcT := builtInTypeID(t, s, "PC")
|
|
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
pc1, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC1", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
pc2, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC2", TypeID: &pcT, X: 400, Y: 0, Width: 100, Height: 35})
|
|
usb := int64(2)
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: mouse.ID, ToDeviceID: pc1.ID, PreferredCableTypeID: &usb,
|
|
})
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: mouse.ID, ToDeviceID: pc2.ID, PreferredCableTypeID: &usb,
|
|
})
|
|
res, _ := s.Solve(p.ID, true)
|
|
if len(res.CablesAdded) != 1 {
|
|
t.Errorf("expected 1 cable to land (one mouse USB), got %d", len(res.CablesAdded))
|
|
}
|
|
if len(res.Unsatisfied) != 1 {
|
|
t.Errorf("expected 1 unsatisfied; got %d (%+v)", len(res.Unsatisfied), res.Unsatisfied)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------- preview vs apply semantics
|
|
|
|
func TestSolve_PreviewDoesNotWrite(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
nasT := builtInTypeID(t, s, "NAS")
|
|
swT := builtInTypeID(t, s, "Switch")
|
|
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
rj45 := int64(5)
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
|
})
|
|
_, _ = s.Solve(p.ID, true) // preview
|
|
cables, _ := s.ListCables(p.ID)
|
|
if len(cables) != 0 {
|
|
t.Errorf("preview wrote %d cables; want 0", len(cables))
|
|
}
|
|
}
|
|
|
|
func TestSolve_ApplyThenIdempotent(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
nasT := builtInTypeID(t, s, "NAS")
|
|
swT := builtInTypeID(t, s, "Switch")
|
|
nas, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", TypeID: &nasT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
sw, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", TypeID: &swT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
rj45 := int64(5)
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: nas.ID, ToDeviceID: sw.ID, PreferredCableTypeID: &rj45,
|
|
})
|
|
r1, _ := s.Solve(p.ID, false)
|
|
if len(r1.CablesAdded) != 1 {
|
|
t.Fatalf("first apply: cables_added=%d, want 1", len(r1.CablesAdded))
|
|
}
|
|
r2, _ := s.Solve(p.ID, false)
|
|
if len(r2.CablesAdded) != 0 {
|
|
t.Errorf("second apply: cables_added=%d, want 0 (idempotent)", len(r2.CablesAdded))
|
|
}
|
|
if len(r2.CablesKept) != 1 {
|
|
t.Errorf("second apply: cables_kept=%d, want 1", len(r2.CablesKept))
|
|
}
|
|
}
|
|
|
|
func TestSolve_ManualCableReservesPort(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
mouseT := builtInTypeID(t, s, "Mouse")
|
|
pcT := builtInTypeID(t, s, "PC")
|
|
mouse, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Mouse", TypeID: &mouseT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
pc, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 200, Y: 0, Width: 100, Height: 35})
|
|
|
|
// Manual cable USB Mouse↔PC: claims the only mouse USB port.
|
|
ports, _ := s.ListPortsForProject(p.ID)
|
|
var mouseUSB, pcUSB int64
|
|
for _, prt := range ports {
|
|
if prt.DeviceID == mouse.ID && prt.TypeID == 2 {
|
|
mouseUSB = prt.ID
|
|
}
|
|
if prt.DeviceID == pc.ID && prt.TypeID == 2 {
|
|
pcUSB = prt.ID
|
|
break
|
|
}
|
|
}
|
|
usb := int64(2)
|
|
_, _ = s.CreateCable(p.ID, CableCreate{
|
|
TypeID: usb,
|
|
From: CableEndpoint{PortID: &mouseUSB},
|
|
To: CableEndpoint{PortID: &pcUSB},
|
|
Auto: false,
|
|
})
|
|
|
|
// Now add a requirement that also wants USB on the mouse → no free port.
|
|
_, _ = s.CreateConnectionRequirement(p.ID, ConnectionRequirementCreate{
|
|
FromDeviceID: mouse.ID, ToDeviceID: pc.ID, PreferredCableTypeID: &usb,
|
|
})
|
|
res, _ := s.Solve(p.ID, true)
|
|
if len(res.Unsatisfied) == 0 {
|
|
t.Errorf("expected unsatisfied req (manual cable should reserve the only mouse USB port)")
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------- setup templates
|
|
|
|
func TestApplyTemplate_LivingRoom(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
tmpls, _ := s.ListSetupTemplates()
|
|
var lr SetupTemplate
|
|
for _, tm := range tmpls {
|
|
if tm.Name == "Living Room" {
|
|
lr = tm
|
|
break
|
|
}
|
|
}
|
|
if lr.ID == 0 {
|
|
t.Fatal("Living Room template not seeded")
|
|
}
|
|
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
|
if err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
if len(res.DevicesAdded) != 3 {
|
|
t.Errorf("devices added = %d, want 3 (TV, Soundbar, ChromeCast)", len(res.DevicesAdded))
|
|
}
|
|
if len(res.RequirementsAdded) != 2 {
|
|
t.Errorf("requirements added = %d, want 2 (TV↔Soundbar, TV↔ChromeCast)", len(res.RequirementsAdded))
|
|
}
|
|
// Ports were seeded as part of the device creation.
|
|
ports, _ := s.ListPortsForProject(p.ID)
|
|
if len(ports) < 6 { // TV(3) + Soundbar(2) + ChromeCast(2) = 7
|
|
t.Errorf("ports after template apply = %d, expected ≥6", len(ports))
|
|
}
|
|
}
|
|
|
|
func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
tmpls, _ := s.ListSetupTemplates()
|
|
var ho SetupTemplate
|
|
for _, tm := range tmpls {
|
|
if tm.Name == "Home Office" {
|
|
ho = tm
|
|
break
|
|
}
|
|
}
|
|
if _, err := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{}); err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
res, err := s.Solve(p.ID, false)
|
|
if err != nil {
|
|
t.Fatalf("solve: %v", err)
|
|
}
|
|
if len(res.CablesAdded) != 3 {
|
|
t.Errorf("Home Office should solve to 3 cables (PC↔Screen, PC↔Keyboard, PC↔Mouse); got %d", len(res.CablesAdded))
|
|
}
|
|
if len(res.Unsatisfied) != 0 {
|
|
t.Errorf("unsatisfied = %+v, want []", res.Unsatisfied)
|
|
}
|
|
}
|
|
|
|
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
tmpls, _ := s.ListSetupTemplates()
|
|
var lr SetupTemplate
|
|
for _, tm := range tmpls {
|
|
if tm.Name == "Living Room" {
|
|
lr = tm
|
|
break
|
|
}
|
|
}
|
|
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
|
if err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
if len(res.FramesAdded) != 1 {
|
|
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
|
}
|
|
frame := res.FramesAdded[0]
|
|
if frame.Name != "Living Room" {
|
|
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
|
|
}
|
|
for _, d := range res.DevicesAdded {
|
|
if d.FrameID == nil || *d.FrameID != frame.ID {
|
|
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
|
|
}
|
|
// Device top-left should be inside the frame rect.
|
|
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
|
|
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
|
|
}
|
|
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
|
|
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
|
|
}
|
|
}
|
|
// No two devices share the same (X, Y) — the grid layout spreads them out.
|
|
seen := map[[2]float64]string{}
|
|
for _, d := range res.DevicesAdded {
|
|
key := [2]float64{d.X, d.Y}
|
|
if prev, ok := seen[key]; ok {
|
|
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
|
|
}
|
|
seen[key] = d.Name
|
|
}
|
|
}
|
|
|
|
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
// Pre-create a frame called "Living Room" so the template's frame name collides.
|
|
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
|
|
tmpls, _ := s.ListSetupTemplates()
|
|
var lr SetupTemplate
|
|
for _, tm := range tmpls {
|
|
if tm.Name == "Living Room" {
|
|
lr = tm
|
|
break
|
|
}
|
|
}
|
|
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
|
if err != nil {
|
|
t.Fatalf("apply: %v", err)
|
|
}
|
|
if len(res.FramesAdded) != 1 {
|
|
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
|
}
|
|
if res.FramesAdded[0].Name != "Living Room 2" {
|
|
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
|
|
}
|
|
}
|
|
|
|
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
pcT := builtInTypeID(t, s, "PC")
|
|
// Pre-create a device called "PC" so the Home Office template's PC collides.
|
|
_, _ = s.CreateDevice(p.ID, DeviceCreate{Name: "PC", TypeID: &pcT, X: 0, Y: 0, Width: 100, Height: 35})
|
|
|
|
tmpls, _ := s.ListSetupTemplates()
|
|
var ho SetupTemplate
|
|
for _, tm := range tmpls {
|
|
if tm.Name == "Home Office" {
|
|
ho = tm
|
|
break
|
|
}
|
|
}
|
|
res, _ := s.ApplyTemplate(p.ID, ho.ID, ApplyTemplateOptions{})
|
|
if len(res.SkippedDevices) == 0 {
|
|
t.Errorf("expected at least one skipped device for name collision; got %+v", res.SkippedDevices)
|
|
}
|
|
if len(res.RequirementsSkipped) == 0 {
|
|
t.Errorf("PC requirements should be skipped when PC device skipped; got %+v", res.RequirementsSkipped)
|
|
}
|
|
}
|