Migration 003 adds the solver's per-project input table + the auto flag
that slice 6 will use to distinguish solver-owned cables from m's
hand-drawn ones.
connection_requirements:
- (from_device_id, to_device_id, preferred_cable_type_id) with
preferred_cable_type_id nullable ("solver picks if exactly one type
matches both ends").
- (pair_lo, pair_hi) is the order-normalised MIN/MAX of (from, to),
stored alongside the m-facing from/to so the UI doesn't have to
denormalise.
- UNIQUE (project_id, pair_lo, pair_hi, preferred_cable_type_id) →
(A,B,T) and (B,A,T) collide; (A,B,Power) + (A,B,RJ45) coexist.
- CHECK (from != to). FK CASCADE from devices → requirement vanishes
if either endpoint device is deleted.
Store + 11 new tests:
- pair normalisation rejects the reversed-direction duplicate
- different cable types on the same pair coexist
- self-loop rejected (ErrInvalidInput)
- cross-project device reference rejected
- two null-cable-type reqs on the same pair both succeed (SQLite NULL
!= NULL in UNIQUE — semantically "solver picks both times", second
wins)
- partial PATCH: preferred_cable_type_id tri-state (leave/set/clear),
must_connect bool, notes string
- device delete cascades to its requirements
- snapshot.connection_requirements is non-nil and populated
cables.auto:
- ALTER TABLE cables ADD COLUMN auto INTEGER NOT NULL DEFAULT 0 CHECK
(auto IN (0,1)). Slice 6 sets 1 from the solver; slice 7's manual
cable POST keeps the default 0.
182 lines
6.0 KiB
Go
182 lines
6.0 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func setupTwoDevices(t *testing.T, s *Store) (int64, int64, int64) {
|
|
t.Helper()
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
a, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "NAS", X: 0, Y: 0, Width: 100, Height: 35})
|
|
b, _ := s.CreateDevice(p.ID, DeviceCreate{Name: "Switch", X: 200, Y: 0, Width: 100, Height: 35})
|
|
return p.ID, a.ID, b.ID
|
|
}
|
|
|
|
func TestCreateConnReq_Basic(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
rj45 := int64(5)
|
|
r, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if !r.MustConnect {
|
|
t.Errorf("must_connect default should be true")
|
|
}
|
|
if r.PreferredCableTypeID == nil || *r.PreferredCableTypeID != rj45 {
|
|
t.Errorf("preferred_cable_type_id wrong: %+v", r.PreferredCableTypeID)
|
|
}
|
|
}
|
|
|
|
func TestCreateConnReq_PairNormalisationRejectsReverse(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
rj45 := int64(5)
|
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
|
}); err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
// (B, A, RJ45) should collide on UNIQUE (pair_lo, pair_hi, type).
|
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: b, ToDeviceID: a, PreferredCableTypeID: &rj45,
|
|
})
|
|
if !errors.Is(err, ErrConflict) {
|
|
t.Errorf("reverse pair err = %v, want ErrConflict", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateConnReq_DifferentCableTypesCoexist(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
rj45, power := int64(5), int64(1)
|
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
|
}); err != nil {
|
|
t.Fatalf("rj45: %v", err)
|
|
}
|
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &power,
|
|
}); err != nil {
|
|
t.Errorf("power on same pair should be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateConnReq_SelfLoopRejected(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, _ := setupTwoDevices(t, s)
|
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: a,
|
|
})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("self-loop err = %v, want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateConnReq_CrossProjectDeviceRejected(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, _ := setupTwoDevices(t, s)
|
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
|
b2, _ := s.CreateDevice(p2.ID, DeviceCreate{Name: "X", X: 0, Y: 0, Width: 100, Height: 35})
|
|
_, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b2.ID,
|
|
})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("cross-project to-device err = %v, want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateConnReq_NullCableTypeUniqueByPair(t *testing.T) {
|
|
// Two NULL-cable-type reqs on the same pair are NOT a conflict in
|
|
// SQLite (NULL != NULL in UNIQUE comparisons). This is fine — they
|
|
// represent "solver picks" both times; the second wins when solving.
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b,
|
|
}); err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
if _, err := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b,
|
|
}); err != nil {
|
|
t.Errorf("second NULL-type req should be allowed (SQLite NULL != NULL): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateConnReq_PartialFields(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
rj45, power := int64(5), int64(1)
|
|
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b, PreferredCableTypeID: &rj45,
|
|
})
|
|
notes := "important"
|
|
must := false
|
|
updated, err := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
|
|
PreferredCableTypeID: FrameRef{Set: true, ID: &power},
|
|
MustConnect: &must,
|
|
Notes: ¬es,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.PreferredCableTypeID == nil || *updated.PreferredCableTypeID != power {
|
|
t.Errorf("cable type not switched: %+v", updated.PreferredCableTypeID)
|
|
}
|
|
if updated.MustConnect {
|
|
t.Errorf("must_connect should be false")
|
|
}
|
|
if updated.Notes != "important" {
|
|
t.Errorf("notes = %q", updated.Notes)
|
|
}
|
|
|
|
// Clear the cable type.
|
|
cleared, _ := s.UpdateConnectionRequirement(pid, r.ID, ConnectionRequirementUpdate{
|
|
PreferredCableTypeID: FrameRef{Set: true, ID: nil},
|
|
})
|
|
if cleared.PreferredCableTypeID != nil {
|
|
t.Errorf("preferred_cable_type_id should be nil after clear; got %v", *cleared.PreferredCableTypeID)
|
|
}
|
|
}
|
|
|
|
func TestDeleteConnReq_CascadesOnDeviceDelete(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
r, _ := s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b,
|
|
})
|
|
if err := s.DeleteDevice(pid, a); err != nil {
|
|
t.Fatalf("delete device a: %v", err)
|
|
}
|
|
if _, err := s.GetConnectionRequirement(pid, r.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("requirement should be gone after device delete; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSnapshot_IncludesConnectionRequirements(t *testing.T) {
|
|
s := newTestStore(t)
|
|
pid, a, b := setupTwoDevices(t, s)
|
|
_, _ = s.CreateConnectionRequirement(pid, ConnectionRequirementCreate{
|
|
FromDeviceID: a, ToDeviceID: b,
|
|
})
|
|
snap, err := s.Snapshot(pid)
|
|
if err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if len(snap.ConnectionRequirements) != 1 {
|
|
t.Errorf("snapshot.connection_requirements = %d, want 1", len(snap.ConnectionRequirements))
|
|
}
|
|
}
|
|
|
|
func TestDeleteConnReq_NotFound(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
if err := s.DeleteConnectionRequirement(p.ID, 999); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("got %v, want ErrNotFound", err)
|
|
}
|
|
}
|