Schema already in 001_init.sql; this is just the Go store layer. IO markers are project-scoped wall-outlet terminators (a cable's "this end plugs into a wall socket outside the diagram" endpoint). Power-by-convention; no schema-level type enforcement. - CreateIOMarker validates frame_id is in the same project (cross-project ref → ErrInvalidInput), defaults label to "IO" when blank. - GetIOMarker is project-scoped — wrong-project read returns ErrNotFound. - UpdateIOMarker uses the FrameRef tri-state for frame_id (same as DeviceUpdate) so callers can clear it explicitly. - DeleteIOMarker is direct delete — ON DELETE SET NULL from the schema drops the io_markers.frame_id ref cleanly when the frame is deleted (verified by TestDeleteFrame_SetsIOMarkerFrameIDToNull). Snapshot now populates IOMarkers from the store; field type tightened from []any to []IOMarker. 7 new table-driven tests, all green with -race.
114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateIOMarker_DefaultsLabel(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if m.Label != "IO" {
|
|
t.Errorf("default label = %q, want IO", m.Label)
|
|
}
|
|
if m.FrameID != nil {
|
|
t.Errorf("frame_id = %v, want nil", m.FrameID)
|
|
}
|
|
}
|
|
|
|
func TestCreateIOMarker_CustomLabel(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if m.Label != "Wall A" {
|
|
t.Errorf("label = %q, want Wall A", m.Label)
|
|
}
|
|
}
|
|
|
|
func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p1, _ := s.CreateProject("LOFT", "", "")
|
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
|
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
|
_, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p1, _ := s.CreateProject("LOFT", "", "")
|
|
p2, _ := s.CreateProject("OFFICE", "", "")
|
|
m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0})
|
|
if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateIOMarker_FrameIDTriState(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
|
|
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0})
|
|
|
|
// Leave alone — passing a different X must not clear frame_id.
|
|
nx := 99.0
|
|
u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx})
|
|
if u1.FrameID == nil || *u1.FrameID != f.ID {
|
|
t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID)
|
|
}
|
|
|
|
// Clear.
|
|
u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}})
|
|
if u2.FrameID != nil {
|
|
t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID)
|
|
}
|
|
}
|
|
|
|
func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
|
|
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20})
|
|
if m.FrameID == nil {
|
|
t.Fatalf("pre-condition: io marker should have frame_id")
|
|
}
|
|
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
|
|
t.Fatalf("delete frame: %v", err)
|
|
}
|
|
m2, _ := s.GetIOMarker(p.ID, m.ID)
|
|
if m2.FrameID != nil {
|
|
t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID)
|
|
}
|
|
}
|
|
|
|
func TestSnapshot_PopulatesIOMarkers(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20})
|
|
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200})
|
|
snap, err := s.Snapshot(p.ID)
|
|
if err != nil {
|
|
t.Fatalf("snapshot: %v", err)
|
|
}
|
|
if len(snap.IOMarkers) != 2 {
|
|
t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers))
|
|
}
|
|
}
|
|
|
|
func TestDeleteIOMarker_NotFound(t *testing.T) {
|
|
s := newTestStore(t)
|
|
p, _ := s.CreateProject("LOFT", "", "")
|
|
if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("got %v, want ErrNotFound", err)
|
|
}
|
|
}
|