Migration 004:
- setup_templates + setup_template_devices + setup_template_requirements
- 3 built-in templates seeded: Living Room (TV+Soundbar+ChromeCast,
2× HDMI), Home Office (PC+Screen+Keyboard+Mouse, 1× HDMI + 2× USB),
Server Rack (NAS+Switch+fritz, 2× RJ45).
Cables store (cables.go):
- CRUD with endpoint validation (port|device|io exactly-one, project-
scoped). Tx-aware: validateEndpointEx + assertCableTypeEx avoid
deadlocks when the solver Apply tx holds the MaxOpenConns(1) connection.
Bundles store (bundles.go):
- CRUD with cable_ids replacement on PATCH. createBundle(ex, …, ownTx)
inherits the caller's tx for solver-internal use; returns a locally-
constructed Bundle when ownTx=false (re-fetching via s.db would
deadlock).
Solver (solver.go) implements design v4.1 §5b.2 exactly:
- Pre-fetch devices/ports/cables/requirements/bundles.
- Reserve ports used by manual cables (auto=0) so the solver can't
reuse them.
- For each requirement (must_connect DESC, id ASC):
* Resolve cable type: preferred, or T = port-types(from) ∩
port-types(to). |T|==0 → unsatisfied "no compat type"; |T|>1 →
"ambiguous"; |T|==1 → that one.
* Pick lowest-id free port on each side. None → unsatisfied with
WhichSide hint + cable-type name.
- Endpoint-pair bundle: ≥2 staged cables between the same device pair
→ auto bundle.
- Diff against existing auto cables by (type_id, MIN(from,to), MAX(from,to))
signature. Matched = kept; new = added; orphans = removed.
- Preview returns the diff without writing; Apply runs in a single tx
that wipes auto bundles, deletes orphan auto cables, inserts new
ones, and rebuilds bundles.
- PortsAndResolve: combo helper for the inspector quick-fix —
inserts a port + re-runs Solve.
Setup-templates store (setup_templates.go):
- List/Get with hydrated devices + requirements.
- ApplyTemplate(projectID, templateID, opts) seeds devices + requirements
in one tx. Per-device name overrides + opt-out. Name collisions skip
the device (skipped_devices); requirements whose endpoints both fail
are also skipped (requirements_skipped). UNIQUE-collision on an
existing requirement is non-fatal; logged in requirements_skipped.
Snapshot: cables + bundles fields tightened to []Cable / []Bundle and
populated from the store.
11 new tests (solver_test.go), all green with -race:
- Basic NAS↔Switch (RJ45) → 1 cable, auto=true
- Ambiguous cable type → unsatisfied
- No free port → unsatisfied with side hint
- Preview doesn't write
- Apply then re-apply → idempotent (kept=N, added=0)
- Manual cable reserves its port → solver can't claim it
- ApplyTemplate Living Room → 3 devices + 2 requirements + 7 ports
(from the device-type port seeder)
- Home Office template then Solve → 3 cables, 0 unsatisfied
- Name-collision pre-existing device → skipped + req-pair skipped
260 lines
9.1 KiB
Go
260 lines
9.1 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_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)
|
|
}
|
|
}
|