| index 63afdcef0..2dce21778 100644 |
| --- a/internal/provision/reconcile_lifetime_test.go |
| +++ b/internal/provision/reconcile_lifetime_test.go |
| @@ -2,9 +2,11 @@ package provision |
| |
| import ( |
| "context" |
| + "database/sql" |
| "errors" |
| "fmt" |
| "os" |
| + "path/filepath" |
| "strings" |
| "sync" |
| "testing" |
| @@ -30,7 +32,10 @@ type lifeScene struct { |
| s *release.DirStore |
| sqid string |
| reg *registry.Registry |
| + path string // the registry's file |
| cfg ReconcileConfig |
| + // dryDeleted is set by a test's DeleteIfHold that a dry run must not call. |
| + dryDeleted bool |
| } |
| |
| func newLifeScene(t *testing.T) *lifeScene { |
| @@ -39,7 +44,12 @@ func newLifeScene(t *testing.T) *lifeScene { |
| if err := PutManifest(ctx, s, sqid, "cart", Manifest{Schema: 1, App: "cart", Domain: "shop.example.com"}); err != nil { |
| t.Fatal(err) |
| } |
| - return &lifeScene{s: s, sqid: sqid, reg: regWith(t), |
| + path := filepath.Join(t.TempDir(), "registry.db") |
| + reg, err := registry.Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &lifeScene{s: s, sqid: sqid, reg: reg, path: path, |
| cfg: ReconcileConfig{Region: "ie", DataDir: "/data", Judge: NewLifetimeJudge()}} |
| } |
| |
| @@ -652,3 +662,482 @@ func TestDeleteIfHoldRefusalLeavesTheRowForTheNextPass(t *testing.T) { |
| t.Fatalf("DeleteIfHold calls %q, want mv_1 then mv_2", calls) |
| } |
| } |
| + |
| +// --- the lifetime pair on rows (self-serve moves plan 1 Task 7) ------------- |
| + |
| +const ( |
| + pairMove = "mv_abcdefghijklmnopqrstuvwxyz" // X: the predecessor's move |
| + pairMove2 = "mv_zyxwvutsrqponmlkjihgfedcba" // Y: the successor's own move |
| +) |
| + |
| +// offlineHoldAt is the offline hold as a box in region here asks it |
| +// (internal/host's offlineHold, over plain region codes): the source holds |
| +// moving from stopping through sealed, the target holds landing once the |
| +// record names it. |
| +func offlineHoldAt(here string) func(Record) (string, string, bool) { |
| + return func(r Record) (string, string, bool) { |
| + m := r.Move |
| + if !m.Offline() { |
| + return "", "", false |
| + } |
| + switch m.State { |
| + case OfflineStopping, OfflineSealing, OfflineSealed: |
| + if m.From == here { |
| + return "moving", m.ID, true |
| + } |
| + case OfflineLanding, OfflineLanded: |
| + if r.Region == m.To && m.To == here { |
| + return "landing", m.ID, true |
| + } |
| + } |
| + return "", "", false |
| + } |
| +} |
| + |
| +// movePair is the pair a hold or a release names: the record's lifetime and |
| +// the move's sequence (internal/host's PairOf). |
| +func movePair(r Record) registry.Pair { |
| + p := registry.Pair{Incarnation: r.Incarnation} |
| + if r.Move != nil { |
| + p.ServingSeq = r.Move.Seq |
| + } |
| + return p |
| +} |
| + |
| +// offline arms the scene's pass with the offline hold and the pair, as a |
| +// claims-off box's reconcileSerializedWith does. |
| +func (x *lifeScene) offline() { |
| + x.cfg.Hold, x.cfg.PairOf = offlineHoldAt(x.cfg.Region), movePair |
| + x.cfg.DeleteIfHold = x.reg.DeleteIfHoldWithTimers |
| +} |
| + |
| +// lifetime writes host's slot at inc, live, and the shop record at inc with |
| +// move_seq seq (inc 0: a fieldless record and no slot). |
| +func (x *lifeScene) lifetime(t *testing.T, inc, seq uint64) { |
| + t.Helper() |
| + rec := x.record("shop", inc) |
| + rec.MoveSeq = seq |
| + if inc > 0 { |
| + x.putSlot(t, "shop", inc, SlotLive) |
| + } |
| + x.putRecord(t, rec) |
| +} |
| + |
| +// migrate is the lazy migration's end state for the record as it stands, |
| +// its move included: the slot at 1, live, and the record at incarnation 1. |
| +func (x *lifeScene) migrate(t *testing.T) { |
| + t.Helper() |
| + rec := x.current(t) |
| + rec.Incarnation = 1 |
| + x.putSlot(t, "shop", 1, SlotLive) |
| + x.putRecord(t, rec) |
| +} |
| + |
| +func (x *lifeScene) current(t *testing.T) Record { |
| + t.Helper() |
| + rec, err := GetRecord(ctx, x.s, x.sqid, "shop", lifeHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return *rec |
| +} |
| + |
| +// move applies offline transitions to the shop record through the writer. |
| +func (x *lifeScene) move(t *testing.T, muts ...func(*Record) error) Record { |
| + t.Helper() |
| + var rec *Record |
| + for _, mut := range muts { |
| + var err error |
| + if rec, err = SetRecordMove(ctx, x.s, x.sqid, "shop", lifeHost, mut); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return *rec |
| +} |
| + |
| +// The move's steps from rest, for a host in ie moving to uk. |
| +var pairNow = time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| + |
| +func startAs(id string) func(*Record) error { |
| + return OfflineStart(id, "ie", "uk", "ops@example.com", pairNow) |
| +} |
| +func throughLanded(id string) []func(*Record) error { |
| + return []func(*Record) error{OfflineSeal(id, "box-a", []string{"box-a"}), |
| + OfflineSealedBy(id, "box-a", MoveSource{Release: "v1"}), OfflineFlip(id), OfflineLandedBy(id, "uk")} |
| +} |
| + |
| +func (x *lifeScene) pairIs(t *testing.T, want registry.Pair, status, op string) { |
| + t.Helper() |
| + rt := x.row(t) |
| + if rt == nil { |
| + t.Fatalf("no row, want %s %q at %+v", status, op, want) |
| + } |
| + if got := (registry.Pair{Incarnation: rt.Incarnation, ServingSeq: rt.ServingSeq}); got != want || rt.Status != status || rt.HoldOp != op { |
| + t.Fatalf("row %s %q at %+v, want %s %q at %+v", rt.Status, rt.HoldOp, got, status, op, want) |
| + } |
| +} |
| + |
| +// rowWrite is one insert or update of the host's row, as a trigger on the |
| +// registry's own table saw it. |
| +type rowWrite struct { |
| + Status, Op string |
| + Pair registry.Pair |
| +} |
| + |
| +// writes arms a hook after every registry write of a routes row (a trigger |
| +// in the registry's file) and returns the reader of the writes since the |
| +// last read. |
| +func (x *lifeScene) writes(t *testing.T) func() []rowWrite { |
| + t.Helper() |
| + db, err := sql.Open("sqlite", x.path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { db.Close() }) |
| + for _, stmt := range []string{ |
| + `CREATE TABLE IF NOT EXISTS t7_writes (n INTEGER PRIMARY KEY AUTOINCREMENT, host TEXT, status TEXT, hold_op TEXT, incarnation INTEGER, serving_seq INTEGER)`, |
| + `CREATE TRIGGER IF NOT EXISTS t7_u AFTER UPDATE ON routes BEGIN INSERT INTO t7_writes (host, status, hold_op, incarnation, serving_seq) VALUES (new.host, new.status, new.hold_op, new.incarnation, new.serving_seq); END`, |
| + `CREATE TRIGGER IF NOT EXISTS t7_i AFTER INSERT ON routes BEGIN INSERT INTO t7_writes (host, status, hold_op, incarnation, serving_seq) VALUES (new.host, new.status, new.hold_op, new.incarnation, new.serving_seq); END`, |
| + } { |
| + if _, err := db.Exec(stmt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return func() []rowWrite { |
| + t.Helper() |
| + rows, err := db.Query(`SELECT status, hold_op, incarnation, serving_seq FROM t7_writes WHERE host = ? ORDER BY n`, lifeHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + var out []rowWrite |
| + for rows.Next() { |
| + var w rowWrite |
| + if err := rows.Scan(&w.Status, &w.Op, &w.Pair.Incarnation, &w.Pair.ServingSeq); err != nil { |
| + t.Fatal(err) |
| + } |
| + out = append(out, w) |
| + } |
| + if _, err := db.Exec(`DELETE FROM t7_writes`); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| + } |
| +} |
| + |
| +// pairChanges is the writes that moved the row's pair, from before. |
| +func pairChanges(before registry.Pair, ws []rowWrite) []rowWrite { |
| + var out []rowWrite |
| + for _, w := range ws { |
| + if w.Pair != before { |
| + out = append(out, w) |
| + before = w.Pair |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func TestRouteForCopiesThePair(t *testing.T) { |
| + rec := shopRecord("bab") |
| + if rt := RouteFor(rec, "/data", "", false, nil); rt.Incarnation != 0 || rt.ServingSeq != 0 { |
| + t.Fatalf("a fieldless record minted (%d, %d)", rt.Incarnation, rt.ServingSeq) |
| + } |
| + rec.Incarnation, rec.MoveSeq = 2, 3 |
| + rec.Move = &MoveState{Kind: MoveKindOffline, ID: pairMove, To: "uk", State: OfflineOpen, Seq: 7} |
| + if rt := RouteFor(rec, "/data", "", true, nil); rt.Incarnation != 2 || rt.ServingSeq != 3 { |
| + t.Fatalf("RouteFor minted (%d, %d), want the record's (2, 3)", rt.Incarnation, rt.ServingSeq) |
| + } |
| +} |
| + |
| +// A row minted outside a move (a rebuilt registry) takes the record's pair: |
| +// the lifetime and the last finished or aborted move's move_seq. |
| +func TestReconcileInstallsTheRecordsPairOnAMintedRow(t *testing.T) { |
| + for _, c := range []struct { |
| + name, region string |
| + muts []func(*Record) error |
| + }{ |
| + {"after finish", "uk", append(append([]func(*Record) error{startAs(pairMove)}, throughLanded(pairMove)...), |
| + OfflineOpenTransition(pairMove, pairNow.Add(time.Hour)), OfflineFinishingTransition(pairMove), OfflineFinish(pairMove, pairNow.Add(2*time.Hour)))}, |
| + {"after abort then finish", "ie", []func(*Record) error{startAs(pairMove), OfflineAbort(pairMove), OfflineFinish(pairMove, pairNow.Add(time.Hour))}}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.cfg.Region = c.region |
| + x.offline() |
| + x.lifetime(t, 1, 0) |
| + if rec := x.move(t, c.muts...); rec.Move != nil || rec.MoveSeq != 3 || rec.Region != c.region { |
| + t.Fatalf("fixture: record %+v move_seq %d region %q", rec.Move, rec.MoveSeq, rec.Region) |
| + } |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 3}, "ready", "") |
| + }) |
| + } |
| +} |
| + |
| +// The target's row is held at the hold's pair, opened at open's, and keeps |
| +// open's through finishing and finish: observing a newer record does not move |
| +// a row's sequence. |
| +func TestAnExistingRowKeepsOpenSequenceThroughFinish(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.cfg.Region = "uk" |
| + x.offline() |
| + x.lifetime(t, 1, 0) |
| + x.move(t, startAs(pairMove)) |
| + x.move(t, throughLanded(pairMove)[:3]...) // landing: the target holds |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 1}, "landing", pairMove) |
| + x.move(t, OfflineLandedBy(pairMove, "uk")) |
| + rec := x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour))) |
| + // The target's open releases with the pair of the record it observed. |
| + if ok, err := x.reg.ReleaseHoldSeq(lifeHost, pairMove, movePair(rec)); err != nil || !ok { |
| + t.Fatalf("open's release: %v %v", ok, err) |
| + } |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 2}, "ready", "") |
| + for _, mut := range []func(*Record) error{OfflineFinishingTransition(pairMove), OfflineFinish(pairMove, pairNow.Add(2*time.Hour))} { |
| + x.pass(t) |
| + x.move(t, mut) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 2}, "ready", "") |
| + } |
| + if rec := x.current(t); rec.MoveSeq != 3 { |
| + t.Fatalf("fixture: move_seq %d after finish", rec.MoveSeq) |
| + } |
| +} |
| + |
| +func TestExistingRowsAcrossLifetimes(t *testing.T) { |
| + t.Run("ready legacy row, migrated record", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.lifetime(t, 0, 0) |
| + x.minted(t, "shop") |
| + x.pairIs(t, registry.Pair{}, "ready", "") |
| + log := x.writes(t) |
| + x.lifetime(t, 1, 0) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 0}, "ready", "") |
| + if ch := pairChanges(registry.Pair{}, log()); len(ch) != 1 { |
| + t.Fatalf("pair writes %+v, want one install", ch) |
| + } |
| + // Same lifetime: a later move_seq does not move the row. |
| + x.lifetime(t, 1, 5) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 0}, "ready", "") |
| + if ch := pairChanges(registry.Pair{Incarnation: 1}, log()); len(ch) != 0 { |
| + t.Fatalf("same lifetime wrote %+v", ch) |
| + } |
| + }) |
| + |
| + t.Run("ready legacy row keeps its own sequence through the migration", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.lifetime(t, 0, 4) |
| + x.minted(t, "shop") |
| + x.pairIs(t, registry.Pair{Incarnation: 0, ServingSeq: 4}, "ready", "") |
| + x.lifetime(t, 1, 6) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 4}, "ready", "") |
| + }) |
| + |
| + t.Run("ready row, recreation, no move", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.lifetime(t, 1, 9) |
| + x.minted(t, "shop") |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 9}, "ready", "") |
| + x.lifetime(t, 2, 0) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 0}, "ready", "") |
| + }) |
| + |
| + t.Run("ready predecessor row, recreation with a started move", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.lifetime(t, 1, 9) |
| + x.minted(t, "shop") |
| + log := x.writes(t) |
| + x.lifetime(t, 2, 0) |
| + if rec := x.move(t, startAs(pairMove2)); movePair(rec) != (registry.Pair{Incarnation: 2, ServingSeq: 1}) { |
| + t.Fatalf("fixture: the successor's hold names %+v", movePair(rec)) |
| + } |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 1}, "moving", pairMove2) |
| + ws := log() |
| + ch := pairChanges(registry.Pair{Incarnation: 1, ServingSeq: 9}, ws) |
| + if len(ch) != 1 || ch[0].Status != "moving" || ch[0].Op != pairMove2 { |
| + t.Fatalf("pair writes %+v, want one held write", ch) |
| + } |
| + for _, w := range ws { |
| + if w.Op == "" && w.Pair.Incarnation == 2 { |
| + t.Fatalf("a ready row carried the successor's pair: %+v (writes %+v)", w, ws) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("held legacy row, migration during a move", func(t *testing.T) { |
| + for _, end := range []string{"open", "abort"} { |
| + t.Run(end, func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.lifetime(t, 0, 0) |
| + x.minted(t, "shop") |
| + x.move(t, startAs(pairMove)) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 0, ServingSeq: 1}, "moving", pairMove) |
| + log := x.writes(t) |
| + x.migrate(t) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 0, ServingSeq: 1}, "moving", pairMove) |
| + if ws := log(); len(ws) != 0 { |
| + t.Fatalf("the installer wrote a row held by the record's own move: %+v", ws) |
| + } |
| + var rec Record |
| + if end == "open" { |
| + x.move(t, throughLanded(pairMove)...) |
| + rec = x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour))) |
| + } else { |
| + rec = x.move(t, OfflineAbort(pairMove)) |
| + } |
| + if ok, err := x.reg.ReleaseHoldSeq(lifeHost, pairMove, movePair(rec)); err != nil || !ok { |
| + t.Fatalf("%s's release: %v %v", end, ok, err) |
| + } |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 2}, "ready", "") |
| + }) |
| + } |
| + }) |
| + |
| + t.Run("hold across migration", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.lifetime(t, 0, 0) |
| + x.minted(t, "shop") |
| + log := x.writes(t) |
| + x.lifetime(t, 1, 0) |
| + x.move(t, startAs(pairMove)) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 1}, "moving", pairMove) |
| + if ch := pairChanges(registry.Pair{}, log()); len(ch) != 1 || ch[0].Op != pairMove { |
| + t.Fatalf("pair writes %+v, want one held write", ch) |
| + } |
| + }) |
| + |
| + // The predecessor's row held by a move its record no longer carries. |
| + held := func(t *testing.T) *lifeScene { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.lifetime(t, 1, 0) |
| + x.minted(t, "shop") |
| + x.move(t, startAs(pairMove)) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 1}, "moving", pairMove) |
| + return x |
| + } |
| + |
| + t.Run("held predecessor by a vanished op", func(t *testing.T) { |
| + x := held(t) |
| + var calls []string |
| + x.cfg.DeleteIfHold = func(host, op string) (bool, error) { |
| + calls = append(calls, op) |
| + return x.reg.DeleteIfHoldWithTimers(host, op) |
| + } |
| + x.lifetime(t, 2, 0) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 0}, "ready", "") |
| + if len(calls) != 1 || calls[0] != pairMove { |
| + t.Fatalf("DeleteIfHold calls %q, want the vanished op's", calls) |
| + } |
| + }) |
| + |
| + for _, c := range []struct { |
| + name string |
| + delete func(x *lifeScene) func(string, string) (bool, error) |
| + dry bool |
| + }{ |
| + {"held predecessor by a vanished op, no callback", func(*lifeScene) func(string, string) (bool, error) { return nil }, false}, |
| + {"held predecessor by a vanished op, refused", func(*lifeScene) func(string, string) (bool, error) { |
| + return func(string, string) (bool, error) { return false, nil } |
| + }, false}, |
| + {"held predecessor by a vanished op, failed", func(*lifeScene) func(string, string) (bool, error) { |
| + return func(string, string) (bool, error) { return false, errors.New("registry busy") } |
| + }, false}, |
| + {"held predecessor by a vanished op, dry run", func(x *lifeScene) func(string, string) (bool, error) { |
| + return func(host, op string) (bool, error) { |
| + x.dryDeleted = true |
| + return x.reg.DeleteIfHoldWithTimers(host, op) |
| + } |
| + }, true}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + x := held(t) |
| + x.cfg.DeleteIfHold, x.cfg.DryRun = c.delete(x), c.dry |
| + x.lifetime(t, 2, 0) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 1}, "moving", pairMove) |
| + if x.dryDeleted { |
| + t.Fatal("a dry run called DeleteIfHold") |
| + } |
| + }) |
| + } |
| + |
| + t.Run("held predecessor, successor with its own move", func(t *testing.T) { |
| + x := held(t) |
| + var calls []string |
| + x.cfg.DeleteIfHold = func(host, op string) (bool, error) { |
| + calls = append(calls, op) |
| + return x.reg.DeleteIfHoldWithTimers(host, op) |
| + } |
| + log := x.writes(t) |
| + x.lifetime(t, 2, 0) |
| + x.move(t, startAs(pairMove2)) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 1}, "moving", pairMove2) |
| + if len(calls) != 1 || calls[0] != pairMove { |
| + t.Fatalf("DeleteIfHold calls %q, want the vanished op's", calls) |
| + } |
| + ws := log() |
| + if len(ws) == 0 || ws[0] != (rowWrite{"moving", pairMove2, registry.Pair{Incarnation: 2, ServingSeq: 1}}) { |
| + t.Fatalf("writes %+v, want the held mint first", ws) |
| + } |
| + for _, w := range ws { |
| + if w.Op == "" { |
| + t.Fatalf("a ready row was written: %+v", ws) |
| + } |
| + } |
| + |
| + // A stale setter: the predecessor's release, or a release naming |
| + // lifetime 1 by the successor's own op, moves nothing. |
| + for _, op := range []string{pairMove, pairMove2} { |
| + if ok, err := x.reg.ReleaseHoldSeq(lifeHost, op, registry.Pair{Incarnation: 1, ServingSeq: 2}); err != nil || ok { |
| + t.Fatalf("a release for lifetime 1 by %s: %v %v", op, ok, err) |
| + } |
| + } |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 1}, "moving", pairMove2) |
| + }) |
| + |
| + t.Run("a stale setter", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.lifetime(t, 2, 0) |
| + x.minted(t, "shop") |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 0}, "ready", "") |
| + if ok, err := x.reg.ReleaseHoldSeq(lifeHost, pairMove, registry.Pair{Incarnation: 1, ServingSeq: 2}); err != nil || ok { |
| + t.Fatalf("a release for lifetime 1: %v %v", ok, err) |
| + } |
| + if ok, err := x.reg.SetHoldIfSeq(lifeHost, "moving", pairMove, "", registry.Pair{Incarnation: 1, ServingSeq: 1}); err != nil || ok { |
| + t.Fatalf("a hold for lifetime 1: %v %v", ok, err) |
| + } |
| + x.pairIs(t, registry.Pair{Incarnation: 2, ServingSeq: 0}, "ready", "") |
| + }) |
| + |
| + t.Run("Phase 1 unchanged", func(t *testing.T) { |
| + x := newLifeScene(t) |
| + x.offline() |
| + x.cfg.PairOf = nil |
| + x.lifetime(t, 1, 3) |
| + x.minted(t, "shop") |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 3}, "ready", "") |
| + x.move(t, startAs(pairMove)) |
| + x.pass(t) |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 3}, "moving", pairMove) |
| + if ok, err := x.reg.ReleaseHold(lifeHost, pairMove); err != nil || !ok { |
| + t.Fatalf("release: %v %v", ok, err) |
| + } |
| + x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 3}, "ready", "") |
| + }) |
| +} |