Steve chose option 3: keep clumps as the default primitive, add a grid/rows fill mode, so sketching and planning are different operations with different outputs rather than one model forced to be both. A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner", useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15 blobs, #77). FillLayout selects what a fill packs: - clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop. - grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could actually plant from. The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps no 15cm floor — its whole point is true spacing — while clump keeps it so a tiny-spacing plant doesn't make invisible clumps. Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid), so "plant the bed in rows" works. Tests: grid produces many more, single-plant plops than clump on the same bed (radius spacing/2, derived count 1); unknown layout is refused at both the service and the API. maxFillPlops still caps a grid fill of a huge bed. No frontend fill affordance exists yet (fill is agent-only in the UI; the fill UI was deferred in #82), so the mode toggle rides along when that's built — noted. Docs: DESIGN placement-model decision. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
896 lines
32 KiB
Go
896 lines
32 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// history returns a garden's change sets newest-first, failing the test on error.
|
|
func history(t *testing.T, s *Service, actor, gardenID int64) []domain.ChangeSet {
|
|
t.Helper()
|
|
sets, _, err := s.GardenHistory(context.Background(), actor, gardenID, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("GardenHistory: %v", err)
|
|
}
|
|
return sets
|
|
}
|
|
|
|
// revisionsOf loads a change set's revisions.
|
|
func revisionsOf(t *testing.T, s *Service, id int64) []domain.Revision {
|
|
t.Helper()
|
|
cs, err := s.store.GetChangeSet(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("GetChangeSet: %v", err)
|
|
}
|
|
return cs.Revisions
|
|
}
|
|
|
|
// TestAutoScopeRecordsEveryMutation is the acceptance criterion that keeps this
|
|
// feature shallow: a plain REST-shaped mutation, with nobody opening a change
|
|
// set, still lands in history.
|
|
func TestAutoScopeRecordsEveryMutation(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
|
t.Fatalf("UpdateObject: %v", err)
|
|
}
|
|
|
|
sets := history(t, s, owner, g.ID)
|
|
if len(sets) != 2 {
|
|
t.Fatalf("got %d change sets, want 2 (create + update)", len(sets))
|
|
}
|
|
// Newest first.
|
|
if sets[0].Summary != "Edited North Bed" {
|
|
t.Errorf("newest summary = %q", sets[0].Summary)
|
|
}
|
|
if sets[0].Source != domain.SourceUI {
|
|
t.Errorf("source = %q, want ui", sets[0].Source)
|
|
}
|
|
if sets[0].ActorID != owner || sets[0].ActorName == "" {
|
|
t.Errorf("actor not resolved: %+v", sets[0])
|
|
}
|
|
if len(sets[0].Counts) != 1 || sets[0].Counts[0].Op != domain.OpUpdate || sets[0].Counts[0].N != 1 {
|
|
t.Errorf("counts = %+v", sets[0].Counts)
|
|
}
|
|
}
|
|
|
|
// TestFillRegionIsOneChangeSet: N plops, one undo. The whole reason the unit of
|
|
// undo is the operation and not the row.
|
|
func TestFillRegionIsOneChangeSet(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump)
|
|
if err != nil {
|
|
t.Fatalf("FillNamedRegion: %v", err)
|
|
}
|
|
if len(created) < 2 {
|
|
t.Fatalf("expected a multi-plop fill, got %d", len(created))
|
|
}
|
|
|
|
sets := history(t, s, owner, g.ID)
|
|
fill := sets[0]
|
|
revs := revisionsOf(t, s, fill.ID)
|
|
if len(revs) != len(created) {
|
|
t.Fatalf("fill produced %d revisions for %d plops", len(revs), len(created))
|
|
}
|
|
for _, r := range revs {
|
|
if r.Op != domain.OpCreate || r.EntityType != domain.EntityPlanting {
|
|
t.Errorf("unexpected revision %+v", r)
|
|
}
|
|
if r.Before != nil || r.After == nil {
|
|
t.Errorf("a create should snapshot only the after state: %+v", r)
|
|
}
|
|
}
|
|
|
|
// Reverting removes all N and leaves the bed as it was.
|
|
_, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
|
if err != nil {
|
|
t.Fatalf("RevertChangeSet: %v", err)
|
|
}
|
|
if len(conflicts) != 0 {
|
|
t.Fatalf("unexpected conflicts: %+v", conflicts)
|
|
}
|
|
active, err := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(active) != 0 {
|
|
t.Errorf("%d plops survived the revert", len(active))
|
|
}
|
|
}
|
|
|
|
// TestRevertRestoresObjectGeometry covers the everyday case: move a bed, undo it.
|
|
func TestRevertRestoresObjectGeometry(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
moved, err := s.UpdateObject(ctx, owner, bed.ID,
|
|
ObjectPatch{XCM: f64Ptr(300), YCM: f64Ptr(400)}, bed.Version)
|
|
if err != nil {
|
|
t.Fatalf("UpdateObject: %v", err)
|
|
}
|
|
|
|
sets := history(t, s, owner, g.ID)
|
|
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
|
|
back, err := s.store.GetObject(ctx, bed.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetObject: %v", err)
|
|
}
|
|
if back.XCM != bed.XCM || back.YCM != bed.YCM {
|
|
t.Errorf("position = (%v,%v), want the original (%v,%v)", back.XCM, back.YCM, bed.XCM, bed.YCM)
|
|
}
|
|
// The revert is a WRITE, not a rewind: the row's version moves forward.
|
|
if back.Version <= moved.Version {
|
|
t.Errorf("version = %d, want > %d (revert is an append-only change)", back.Version, moved.Version)
|
|
}
|
|
}
|
|
|
|
// TestRevertOfRevertRestoresTheChange — undo is undoable, because a revert is
|
|
// itself a change set rather than a rewrite of history.
|
|
func TestRevertOfRevertRestoresTheChange(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
|
t.Fatalf("UpdateObject: %v", err)
|
|
}
|
|
move := history(t, s, owner, g.ID)[0]
|
|
|
|
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceUI)
|
|
if err != nil {
|
|
t.Fatalf("revert: %v", err)
|
|
}
|
|
if undo.RevertsID == nil || *undo.RevertsID != move.ID {
|
|
t.Fatalf("revert doesn't point at its target: %+v", undo)
|
|
}
|
|
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
|
|
t.Fatalf("undo didn't restore x: %v", o.XCM)
|
|
}
|
|
|
|
redo, conflicts, err := s.RevertChangeSet(ctx, owner, undo.ID, domain.SourceUI)
|
|
if err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert of revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 300 {
|
|
t.Errorf("redo didn't reapply the move: x = %v", o.XCM)
|
|
}
|
|
if redo.Summary == "" {
|
|
t.Error("a revert should carry a summary")
|
|
}
|
|
|
|
// The original is marked as reverted, and every step is still in the list.
|
|
sets := history(t, s, owner, g.ID)
|
|
if len(sets) != 4 { // create bed, move, undo, redo
|
|
t.Fatalf("got %d change sets, want 4", len(sets))
|
|
}
|
|
var moveEntry *domain.ChangeSet
|
|
for i := range sets {
|
|
if sets[i].ID == move.ID {
|
|
moveEntry = &sets[i]
|
|
}
|
|
}
|
|
if moveEntry == nil || moveEntry.RevertedByID == nil || *moveEntry.RevertedByID != undo.ID {
|
|
t.Errorf("the move isn't marked as reverted: %+v", moveEntry)
|
|
}
|
|
}
|
|
|
|
// TestRevertConflictsOnLaterEdit is the guard that stops undo from silently
|
|
// discarding someone's newer work — and the promise that the REST of the change
|
|
// set still reverts.
|
|
func TestRevertConflictsOnLaterEdit(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
bedA := seedBed(t, s, owner, g.ID)
|
|
bedB, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{
|
|
Kind: domain.KindBed, Name: "B", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateObject: %v", err)
|
|
}
|
|
|
|
// One change set touching both beds.
|
|
_, err = s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Rearranged"},
|
|
func(ctx context.Context) error {
|
|
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{XCM: f64Ptr(600)}, bedA.Version); err != nil {
|
|
return err
|
|
}
|
|
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{XCM: f64Ptr(300)}, bedB.Version)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WithChangeSet: %v", err)
|
|
}
|
|
turn := history(t, s, owner, g.ID)[0]
|
|
if turn.Source != domain.SourceAgent || len(revisionsOf(t, s, turn.ID)) != 2 {
|
|
t.Fatalf("expected one agent change set with 2 revisions: %+v", turn)
|
|
}
|
|
|
|
// Someone edits bed A afterwards.
|
|
cur, _ := s.store.GetObject(ctx, bedA.ID)
|
|
if _, err := s.UpdateObject(ctx, owner, bedA.ID, ObjectPatch{Name: strPtr("Touched")}, cur.Version); err != nil {
|
|
t.Fatalf("later edit: %v", err)
|
|
}
|
|
|
|
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
|
|
if err != nil {
|
|
t.Fatalf("RevertChangeSet: %v", err)
|
|
}
|
|
if len(conflicts) != 1 {
|
|
t.Fatalf("got %d conflicts, want 1: %+v", len(conflicts), conflicts)
|
|
}
|
|
if conflicts[0].EntityID != bedA.ID || conflicts[0].Reason != domain.ConflictChanged {
|
|
t.Errorf("unexpected conflict %+v", conflicts[0])
|
|
}
|
|
if conflicts[0].Name != "Touched" {
|
|
t.Errorf("conflict should name the entity as it stands now, got %q", conflicts[0].Name)
|
|
}
|
|
|
|
// A was left alone; B still reverted.
|
|
a, _ := s.store.GetObject(ctx, bedA.ID)
|
|
if a.XCM != 600 || a.Name != "Touched" {
|
|
t.Errorf("conflicted object was modified: %+v", a)
|
|
}
|
|
b, _ := s.store.GetObject(ctx, bedB.ID)
|
|
if b.XCM != bedB.XCM {
|
|
t.Errorf("bed B should have reverted to %v, got %v", bedB.XCM, b.XCM)
|
|
}
|
|
}
|
|
|
|
// TestRevertRestoresDeletedObjectWithItsPlantings covers the cascade: deleting an
|
|
// object takes its plops with it at the FK level, where the service never sees
|
|
// them. If they aren't snapshotted the delete is listed in history but can't
|
|
// actually be undone.
|
|
func TestRevertRestoresDeletedObjectWithItsPlantings(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
|
PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 20,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreatePlanting: %v", err)
|
|
}
|
|
if err := s.DeleteObject(ctx, owner, bed.ID); err != nil {
|
|
t.Fatalf("DeleteObject: %v", err)
|
|
}
|
|
|
|
del := history(t, s, owner, g.ID)[0]
|
|
revs := revisionsOf(t, s, del.ID)
|
|
if len(revs) != 2 {
|
|
t.Fatalf("delete recorded %d revisions, want 2 (the bed and its plop)", len(revs))
|
|
}
|
|
|
|
if _, conflicts, err := s.RevertChangeSet(ctx, owner, del.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
|
|
back, err := s.store.GetObject(ctx, bed.ID)
|
|
if err != nil {
|
|
t.Fatalf("bed not restored: %v", err)
|
|
}
|
|
if back.ID != bed.ID || back.Name != bed.Name || back.XCM != bed.XCM {
|
|
t.Errorf("restored bed differs: %+v", back)
|
|
}
|
|
restoredPlop, err := s.store.GetPlanting(ctx, plop.ID)
|
|
if err != nil {
|
|
t.Fatalf("plop not restored: %v", err)
|
|
}
|
|
if restoredPlop.ObjectID != bed.ID || restoredPlop.XCM != plop.XCM {
|
|
t.Errorf("restored plop differs: %+v", restoredPlop)
|
|
}
|
|
}
|
|
|
|
// TestRevertClearObject: "clear bed" is a bulk soft-remove, and undoing it should
|
|
// bring the whole bed back planted.
|
|
func TestRevertClearObject(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
|
t.Fatalf("fill: %v", err)
|
|
}
|
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
|
|
|
n, err := s.ClearObject(ctx, owner, bed.ID)
|
|
if err != nil {
|
|
t.Fatalf("ClearObject: %v", err)
|
|
}
|
|
if n != len(before) {
|
|
t.Fatalf("cleared %d of %d", n, len(before))
|
|
}
|
|
if active, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID); len(active) != 0 {
|
|
t.Fatalf("%d plops still active after clear", len(active))
|
|
}
|
|
|
|
clear := history(t, s, owner, g.ID)[0]
|
|
if _, conflicts, err := s.RevertChangeSet(ctx, owner, clear.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
after, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
|
if len(after) != len(before) {
|
|
t.Errorf("%d plops active after undo, want %d", len(after), len(before))
|
|
}
|
|
}
|
|
|
|
// TestRevertPermissions: a viewer may read history but not undo; a stranger sees
|
|
// neither (existence stays masked).
|
|
func TestRevertPermissions(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
viewer := seedUser(t, s, "[email protected]")
|
|
stranger := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
|
t.Fatalf("AddShare: %v", err)
|
|
}
|
|
_ = bed
|
|
cs := history(t, s, owner, g.ID)[0]
|
|
|
|
if _, _, err := s.GardenHistory(ctx, viewer, g.ID, 0, 0); err != nil {
|
|
t.Errorf("a viewer should be able to read history: %v", err)
|
|
}
|
|
if _, _, err := s.RevertChangeSet(ctx, viewer, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Errorf("viewer revert err = %v, want ErrForbidden", err)
|
|
}
|
|
if _, _, err := s.GardenHistory(ctx, stranger, g.ID, 0, 0); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("stranger history err = %v, want ErrNotFound", err)
|
|
}
|
|
if _, _, err := s.RevertChangeSet(ctx, stranger, cs.ID, domain.SourceUI); !errors.Is(err, domain.ErrNotFound) {
|
|
t.Errorf("stranger revert err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestWithChangeSetRecordsWhatCommittedOnFailure — a turn that fails partway has
|
|
// still committed the mutations it got through, because this scope buffers
|
|
// HISTORY, not data. Dropping the buffer would leave those changes with no
|
|
// history and no way to undo them, which is exactly the situation undo exists
|
|
// for. So they're recorded, marked as partial, and the failure is still reported.
|
|
func TestWithChangeSetRecordsWhatCommittedOnFailure(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
sentinel := errors.New("boom")
|
|
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Half a turn"},
|
|
func(ctx context.Context) error {
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version); err != nil {
|
|
return err
|
|
}
|
|
return sentinel
|
|
})
|
|
if !errors.Is(err, sentinel) {
|
|
t.Fatalf("err = %v, want the sentinel", err)
|
|
}
|
|
|
|
// The move really happened, so it must be in history and undoable.
|
|
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != 600 {
|
|
t.Fatalf("the committed move was rolled back? x = %v", o.XCM)
|
|
}
|
|
sets := history(t, s, owner, g.ID)
|
|
partial := sets[0]
|
|
if !strings.Contains(partial.Summary, "failed partway") {
|
|
t.Errorf("summary = %q, want it to say the turn failed partway", partial.Summary)
|
|
}
|
|
if len(revisionsOf(t, s, partial.ID)) != 1 {
|
|
t.Errorf("expected the one committed mutation to be recorded")
|
|
}
|
|
if _, conflicts, err := s.RevertChangeSet(ctx, owner, partial.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
if o, _ := s.store.GetObject(ctx, bed.ID); o.XCM != bed.XCM {
|
|
t.Errorf("undo of the partial turn didn't restore x: %v", o.XCM)
|
|
}
|
|
}
|
|
|
|
// TestChangeSetSkippedWhenNothingChanged — a scope that mutates nothing writes no
|
|
// change set, so history isn't padded with empty entries.
|
|
func TestChangeSetSkippedWhenNothingChanged(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
before := len(history(t, s, owner, g.ID))
|
|
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Looked around"},
|
|
func(context.Context) error { return nil })
|
|
if err != nil {
|
|
t.Fatalf("WithChangeSet: %v", err)
|
|
}
|
|
if cs != nil {
|
|
t.Errorf("expected no change set, got %+v", cs)
|
|
}
|
|
if got := len(history(t, s, owner, g.ID)); got != before {
|
|
t.Errorf("history grew by %d", got-before)
|
|
}
|
|
}
|
|
|
|
// TestGardenMetadataRevert covers the third entity type.
|
|
func TestGardenMetadataRevert(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{
|
|
Name: "Renamed", WidthCM: 1200, HeightCM: 1000, UnitPref: domain.UnitImperial,
|
|
}, g.Version); err != nil {
|
|
t.Fatalf("UpdateGarden: %v", err)
|
|
}
|
|
cs := history(t, s, owner, g.ID)[0]
|
|
if _, conflicts, err := s.RevertChangeSet(ctx, owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
back, _ := s.store.GetGarden(ctx, g.ID)
|
|
if back.Name != g.Name || back.WidthCM != g.WidthCM {
|
|
t.Errorf("garden not restored: %+v", back)
|
|
}
|
|
// MyRole is computed, never stored — the snapshot must not have carried it
|
|
// back into the row.
|
|
if back.MyRole != "" {
|
|
t.Errorf("MyRole leaked into the stored row: %q", back.MyRole)
|
|
}
|
|
}
|
|
|
|
// TestPlanRevertOrdersRestoresParentFirst pins the ordering the FKs depend on,
|
|
// rather than trusting reverse-seq to get it right by luck.
|
|
func TestPlanRevertOrdersRestoresParentFirst(t *testing.T) {
|
|
revs := []domain.Revision{
|
|
{Seq: 1, EntityType: domain.EntityPlanting, EntityID: 10, Op: domain.OpDelete},
|
|
{Seq: 2, EntityType: domain.EntityObject, EntityID: 1, Op: domain.OpDelete},
|
|
{Seq: 3, EntityType: domain.EntityObject, EntityID: 2, Op: domain.OpCreate},
|
|
{Seq: 4, EntityType: domain.EntityPlanting, EntityID: 11, Op: domain.OpCreate},
|
|
{Seq: 5, EntityType: domain.EntityObject, EntityID: 3, Op: domain.OpUpdate},
|
|
}
|
|
got := planRevert(revs)
|
|
want := []struct {
|
|
entity string
|
|
id int64
|
|
}{
|
|
{domain.EntityObject, 1}, // restore parents first
|
|
{domain.EntityPlanting, 10}, // then children
|
|
{domain.EntityObject, 3}, // then updates
|
|
{domain.EntityPlanting, 11}, // then delete created children
|
|
{domain.EntityObject, 2}, // and only then their parents
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("planned %d revisions, want %d", len(got), len(want))
|
|
}
|
|
for i, w := range want {
|
|
if got[i].EntityType != w.entity || got[i].EntityID != w.id {
|
|
t.Errorf("step %d = %s/%d, want %s/%d", i, got[i].EntityType, got[i].EntityID, w.entity, w.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSnapshotsCaptureVersion — the revert guard compares against the version in
|
|
// the after-snapshot, so it has to actually be there.
|
|
func TestSnapshotsCaptureVersion(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
|
|
revs := revisionsOf(t, s, history(t, s, owner, g.ID)[0].ID)
|
|
if len(revs) != 1 || revs[0].After == nil {
|
|
t.Fatalf("unexpected revisions %+v", revs)
|
|
}
|
|
var snap domain.GardenObject
|
|
if err := json.Unmarshal([]byte(*revs[0].After), &snap); err != nil {
|
|
t.Fatalf("snapshot isn't a garden object: %v", err)
|
|
}
|
|
if snap.Version != bed.Version || snap.ID != bed.ID {
|
|
t.Errorf("snapshot = %+v, want version %d id %d", snap, bed.Version, bed.ID)
|
|
}
|
|
}
|
|
|
|
func f64Ptr(v float64) *float64 { return &v }
|
|
|
|
// TestRevertChangeSetTouchingOneEntityTwice — an agent turn that moves a bed and
|
|
// then renames it holds two revisions for the same object. Each inverse bumps
|
|
// that row's version, so from the second one on the snapshot's version no longer
|
|
// matches the live row. Without tracking what the revert itself just wrote, the
|
|
// guard flags our own work as somebody else's edit and the undo half-fails.
|
|
func TestRevertChangeSetTouchingOneEntityTwice(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
_, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "Two edits"},
|
|
func(ctx context.Context) error {
|
|
moved, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(600)}, bed.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("Renamed")}, moved.Version)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WithChangeSet: %v", err)
|
|
}
|
|
turn := history(t, s, owner, g.ID)[0]
|
|
if len(revisionsOf(t, s, turn.ID)) != 2 {
|
|
t.Fatalf("expected 2 revisions for the same object")
|
|
}
|
|
|
|
_, conflicts, err := s.RevertChangeSet(ctx, owner, turn.ID, domain.SourceUI)
|
|
if err != nil {
|
|
t.Fatalf("RevertChangeSet: %v", err)
|
|
}
|
|
if len(conflicts) != 0 {
|
|
t.Fatalf("the revert conflicted with its own work: %+v", conflicts)
|
|
}
|
|
back, _ := s.store.GetObject(ctx, bed.ID)
|
|
if back.XCM != bed.XCM || back.Name != bed.Name {
|
|
t.Errorf("both edits should have been undone, got x=%v name=%q", back.XCM, back.Name)
|
|
}
|
|
}
|
|
|
|
// TestRevertOfObjectCreateRefusesWhenPlanted — undoing "added a bed" would
|
|
// cascade away anything planted in it since. Those plops would be snapshotted and
|
|
// so recoverable, but deleting someone's plants as a side effect of an unrelated
|
|
// undo should be reported, not performed quietly.
|
|
func TestRevertOfObjectCreateRefusesWhenPlanted(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
create := history(t, s, owner, g.ID)[0]
|
|
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
|
|
}); err != nil {
|
|
t.Fatalf("CreatePlanting: %v", err)
|
|
}
|
|
|
|
cs, conflicts, err := s.RevertChangeSet(ctx, owner, create.ID, domain.SourceUI)
|
|
if err != nil {
|
|
t.Fatalf("RevertChangeSet: %v", err)
|
|
}
|
|
if len(conflicts) != 1 || conflicts[0].EntityID != bed.ID {
|
|
t.Fatalf("expected one conflict naming the bed, got %+v", conflicts)
|
|
}
|
|
if cs != nil {
|
|
t.Errorf("nothing should have been reverted, got change set %+v", cs)
|
|
}
|
|
if _, err := s.store.GetObject(ctx, bed.ID); err != nil {
|
|
t.Errorf("the planted bed was deleted anyway: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestHistoryDoesNotDuplicateMultiplyRevertedEntries — more than one change set
|
|
// can point at the same target (undo, redo, undo again). Looking that up with a
|
|
// LEFT JOIN would emit one duplicate row per revert and silently corrupt the
|
|
// page, so it is a scalar subquery instead. Written against the store because
|
|
// getting a target legitimately reverted twice through the service is hard: the
|
|
// second attempt conflicts, which is itself the correct behaviour.
|
|
func TestHistoryDoesNotDuplicateMultiplyRevertedEntries(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
ctx := context.Background()
|
|
|
|
target := history(t, s, owner, g.ID) // garden create isn't recorded; start empty
|
|
if len(target) != 0 {
|
|
t.Fatalf("expected an empty history, got %+v", target)
|
|
}
|
|
base, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
|
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Base",
|
|
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}})
|
|
if err != nil {
|
|
t.Fatalf("WriteChangeSet: %v", err)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
|
GardenID: g.ID, ActorID: owner, Source: domain.SourceUI, Summary: "Undo", RevertsID: &base.ID,
|
|
}, []domain.Revision{{EntityType: domain.EntityGarden, EntityID: g.ID, Op: domain.OpUpdate}}); err != nil {
|
|
t.Fatalf("WriteChangeSet revert %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
sets := history(t, s, owner, g.ID)
|
|
if len(sets) != 3 {
|
|
t.Fatalf("got %d change sets, want 3 — a join would have duplicated the base row: %+v", len(sets), sets)
|
|
}
|
|
seen := map[int64]int{}
|
|
for _, cs := range sets {
|
|
seen[cs.ID]++
|
|
}
|
|
for id, n := range seen {
|
|
if n != 1 {
|
|
t.Errorf("change set %d appears %d times", id, n)
|
|
}
|
|
}
|
|
for _, cs := range sets {
|
|
if cs.ID == base.ID && (cs.RevertedByID == nil || *cs.RevertedByID == 0) {
|
|
t.Error("the base change set should be marked as reverted")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRevertSourceIsRecorded — an agent undoing its own work must be
|
|
// distinguishable from a person clicking undo, or the history badge lies.
|
|
func TestRevertSourceIsRecorded(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
|
t.Fatalf("UpdateObject: %v", err)
|
|
}
|
|
move := history(t, s, owner, g.ID)[0]
|
|
|
|
undo, _, err := s.RevertChangeSet(ctx, owner, move.ID, domain.SourceAgent)
|
|
if err != nil {
|
|
t.Fatalf("revert: %v", err)
|
|
}
|
|
if undo.Source != domain.SourceAgent {
|
|
t.Errorf("source = %q, want agent", undo.Source)
|
|
}
|
|
if _, _, err := s.RevertChangeSet(ctx, owner, move.ID, "nonsense"); !errors.Is(err, domain.ErrInvalidInput) {
|
|
t.Errorf("an unknown source should be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestClearObjectOnlyClearsWhatItSnapshotted — the clear targets the exact ids it
|
|
// read, so a plop created between the read and the UPDATE can't be removed
|
|
// without a revision to undo it by.
|
|
func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
|
t.Fatalf("fill: %v", err)
|
|
}
|
|
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
|
|
|
|
n, err := s.ClearObject(ctx, owner, bed.ID)
|
|
if err != nil {
|
|
t.Fatalf("ClearObject: %v", err)
|
|
}
|
|
clear := history(t, s, owner, g.ID)[0]
|
|
revs := revisionsOf(t, s, clear.ID)
|
|
// Every row the clear touched has a revision; the count and the revisions agree.
|
|
if n != len(before) || len(revs) != n {
|
|
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
|
}
|
|
}
|
|
|
|
// TestRevertResultCarriesItsCounts — found by using the thing.
|
|
//
|
|
// commitScope returns the freshly inserted row, which carries no tally. A caller
|
|
// receiving a change set with empty Counts cannot tell "nothing was reverted"
|
|
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
|
|
// wrong way: a successful undo reported "nothing left to undo", which is the
|
|
// worst possible thing to say about an action that just worked.
|
|
//
|
|
// It also pins the cross-layer contract that fix created. countRevisions tallies
|
|
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
|
|
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
|
|
// the totals, which would agree even if the groupings had diverged.
|
|
func TestRevertResultCarriesItsCounts(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
ctx := context.Background()
|
|
|
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
|
t.Fatalf("fill: %v", err)
|
|
}
|
|
// A second, different kind of change, so the breakdown has more than one row
|
|
// to get wrong.
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
sets := history(t, s, owner, g.ID)
|
|
rename, fill := sets[0], sets[1]
|
|
|
|
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
|
|
if err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
|
if err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
|
|
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
|
if undo == nil {
|
|
t.Fatal("a revert that did work returned no change set")
|
|
}
|
|
if len(undo.Counts) == 0 {
|
|
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
|
|
}
|
|
}
|
|
// Undoing a fill deletes every plop it created, so the tallies must match.
|
|
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
|
|
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
|
|
}
|
|
|
|
// The SQL tally and the Go tally must agree, row for row.
|
|
listed := history(t, s, owner, g.ID)
|
|
byID := map[int64][]domain.ChangeCount{}
|
|
for _, cs := range listed {
|
|
byID[cs.ID] = cs.Counts
|
|
}
|
|
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
|
fromSQL, ok := byID[undo.ID]
|
|
if !ok {
|
|
t.Fatalf("revert %d is missing from the history list", undo.ID)
|
|
}
|
|
if !sameCounts(undo.Counts, fromSQL) {
|
|
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
|
|
undo.ID, undo.Counts, fromSQL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sameCounts compares two tallies regardless of order.
|
|
func sameCounts(a, b []domain.ChangeCount) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
index := func(cs []domain.ChangeCount) map[string]int {
|
|
m := map[string]int{}
|
|
for _, c := range cs {
|
|
m[c.EntityType+"/"+c.Op] = c.N
|
|
}
|
|
return m
|
|
}
|
|
ia, ib := index(a), index(b)
|
|
for k, v := range ia {
|
|
if ib[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
|
|
func countsTotal(counts []domain.ChangeCount) int {
|
|
n := 0
|
|
for _, c := range counts {
|
|
n += c.N
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
|
|
//
|
|
// The failure path was detached from cancellation; the SUCCESS path was not. An
|
|
// agent turn whose client disconnects can still COMPLETE — and then the success
|
|
// path committed with a dead context, the write failed, and real changes were
|
|
// left with no change set and no way to undo them. Found live with 18 plantings
|
|
// behind no history at all.
|
|
//
|
|
// Nothing about a commit needs the caller to still be there: by the time it
|
|
// runs, the data it describes has already been written.
|
|
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
plant := seedOwnPlant(t, s, owner, 15)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
before := len(history(t, s, owner, g.ID))
|
|
|
|
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
|
|
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
|
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
|
}, func(ctx context.Context) error {
|
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
|
|
return err
|
|
}
|
|
cancel() // the client disconnects, mid-turn, after the work landed
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("WithChangeSet: %v", err)
|
|
}
|
|
if cs == nil {
|
|
t.Fatal("the turn changed things but produced no change set")
|
|
}
|
|
|
|
after := history(t, s, owner, g.ID)
|
|
if len(after) != before+1 {
|
|
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
|
|
}
|
|
if after[0].Summary != "plant beans in the second bed" {
|
|
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
|
|
}
|
|
// And it's undoable, which is the entire point.
|
|
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
|
|
if len(active) != 0 {
|
|
t.Errorf("%d plantings survived the undo", len(active))
|
|
}
|
|
}
|
|
|
|
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
|
|
// path virtually every mutation takes.
|
|
//
|
|
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
|
|
// between the row landing and the change set being written, that change is
|
|
// orphaned exactly as an agent turn's was — and this path is used far more.
|
|
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
|
s := newTestService(t, openConfig())
|
|
owner := seedUser(t, s, "[email protected]")
|
|
g := seedGarden(t, s, owner)
|
|
bed := seedBed(t, s, owner, g.ID)
|
|
before := len(history(t, s, owner, g.ID))
|
|
|
|
// The row write and the history write share this context; cancelling after
|
|
// the mutation returns is the client hanging up mid-request.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
|
t.Fatalf("UpdateObject: %v", err)
|
|
}
|
|
cancel()
|
|
|
|
after := history(t, s, owner, g.ID)
|
|
if len(after) != before+1 {
|
|
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
|
|
}
|
|
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
|
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
|
|
}
|
|
back, _ := s.store.GetObject(context.Background(), bed.ID)
|
|
if back.XCM != bed.XCM {
|
|
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
|
}
|
|
}
|