go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/prjmanager/triager/deps_test.go (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package triager
    16  
    17  import (
    18  	"fmt"
    19  	"testing"
    20  	"time"
    21  
    22  	"google.golang.org/protobuf/proto"
    23  	"google.golang.org/protobuf/types/known/timestamppb"
    24  
    25  	"go.chromium.org/luci/common/clock/testclock"
    26  
    27  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    28  	"go.chromium.org/luci/cv/internal/changelist"
    29  	"go.chromium.org/luci/cv/internal/common"
    30  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    31  	"go.chromium.org/luci/cv/internal/cvtesting"
    32  	"go.chromium.org/luci/cv/internal/prjmanager/prjpb"
    33  	"go.chromium.org/luci/cv/internal/run"
    34  
    35  	. "github.com/smartystreets/goconvey/convey"
    36  	. "go.chromium.org/luci/common/testing/assertions"
    37  )
    38  
    39  func TestDepsTriage(t *testing.T) {
    40  	t.Parallel()
    41  
    42  	Convey("Component's PCL deps triage", t, func() {
    43  		ct := cvtesting.Test{}
    44  		ctx, cancel := ct.SetUp(t)
    45  		defer cancel()
    46  
    47  		// Truncate start time point s.t. easy to see diff in test failures.
    48  		epoch := testclock.TestRecentTimeUTC.Truncate(10000 * time.Second)
    49  		dryRun := func(t time.Time) *run.Triggers {
    50  			return &run.Triggers{CqVoteTrigger: &run.Trigger{Mode: string(run.DryRun), Time: timestamppb.New(t)}}
    51  		}
    52  		fullRun := func(t time.Time) *run.Triggers {
    53  			return &run.Triggers{CqVoteTrigger: &run.Trigger{Mode: string(run.FullRun), Time: timestamppb.New(t)}}
    54  		}
    55  
    56  		sup := &simplePMState{
    57  			pb: &prjpb.PState{},
    58  			cgs: []*prjcfg.ConfigGroup{
    59  				{ID: "hash/singular", Content: &cfgpb.ConfigGroup{}},
    60  				{ID: "hash/combinable", Content: &cfgpb.ConfigGroup{CombineCls: &cfgpb.CombineCLs{}}},
    61  				{ID: "hash/another", Content: &cfgpb.ConfigGroup{}},
    62  			},
    63  		}
    64  		const singIdx, combIdx, anotherIdx = 0, 1, 2
    65  
    66  		do := func(pcl *prjpb.PCL, cgIdx int32) *triagedDeps {
    67  			backup := prjpb.PState{}
    68  			proto.Merge(&backup, sup.pb)
    69  
    70  			// Actual component doesn't matter in this test.
    71  			td := triageDeps(ctx, pcl, cgIdx, pmState{sup})
    72  			So(sup.pb, ShouldResembleProto, &backup) // must not be modified
    73  			return td
    74  		}
    75  
    76  		Convey("Singluar and Combinable behave the same", func() {
    77  			sameTests := func(name string, cgIdx int32) {
    78  				Convey(name, func() {
    79  					Convey("no deps", func() {
    80  						sup.pb.Pcls = []*prjpb.PCL{
    81  							{Clid: 33, ConfigGroupIndexes: []int32{cgIdx}},
    82  						}
    83  						td := do(sup.pb.Pcls[0], cgIdx)
    84  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{})
    85  						So(td.OK(), ShouldBeTrue)
    86  					})
    87  
    88  					Convey("Valid CL stack CQ+1", func() {
    89  						sup.pb.Pcls = []*prjpb.PCL{
    90  							{Clid: 31, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(3 * time.Second))},
    91  							{Clid: 32, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(2 * time.Second))},
    92  							{Clid: 33, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(1 * time.Second)),
    93  								Deps: []*changelist.Dep{
    94  									{Clid: 31, Kind: changelist.DepKind_SOFT},
    95  									{Clid: 32, Kind: changelist.DepKind_HARD},
    96  								}},
    97  						}
    98  						td := do(sup.PCL(33), cgIdx)
    99  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   100  							lastCQVoteTriggered: epoch.Add(3 * time.Second),
   101  						})
   102  						So(td.OK(), ShouldBeTrue)
   103  					})
   104  
   105  					Convey("Not yet loaded deps", func() {
   106  						sup.pb.Pcls = []*prjpb.PCL{
   107  							// 31 isn't in PCLs yet
   108  							{Clid: 32, Status: prjpb.PCL_UNKNOWN},
   109  							{Clid: 33, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(1 * time.Second)),
   110  								Deps: []*changelist.Dep{
   111  									{Clid: 31, Kind: changelist.DepKind_SOFT},
   112  									{Clid: 32, Kind: changelist.DepKind_HARD},
   113  								}},
   114  						}
   115  						pcl33 := sup.PCL(33)
   116  						td := do(pcl33, cgIdx)
   117  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{notYetLoaded: pcl33.GetDeps()})
   118  						So(td.OK(), ShouldBeTrue)
   119  					})
   120  
   121  					Convey("Unwatched", func() {
   122  						sup.pb.Pcls = []*prjpb.PCL{
   123  							{Clid: 31, Status: prjpb.PCL_UNWATCHED},
   124  							{Clid: 32, Status: prjpb.PCL_DELETED},
   125  							{Clid: 33, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(1 * time.Second)),
   126  								Deps: []*changelist.Dep{
   127  									{Clid: 31, Kind: changelist.DepKind_SOFT},
   128  									{Clid: 32, Kind: changelist.DepKind_HARD},
   129  								}},
   130  						}
   131  						pcl33 := sup.PCL(33)
   132  						td := do(pcl33, cgIdx)
   133  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   134  							invalidDeps: &changelist.CLError_InvalidDeps{
   135  								Unwatched: pcl33.GetDeps(),
   136  							},
   137  						})
   138  						So(td.OK(), ShouldBeFalse)
   139  					})
   140  
   141  					Convey("Submitted can be in any config group and they are OK deps", func() {
   142  						sup.pb.Pcls = []*prjpb.PCL{
   143  							{Clid: 32, ConfigGroupIndexes: []int32{anotherIdx}, Submitted: true},
   144  							{Clid: 33, ConfigGroupIndexes: []int32{cgIdx}, Triggers: dryRun(epoch.Add(1 * time.Second)),
   145  								Deps: []*changelist.Dep{{Clid: 32, Kind: changelist.DepKind_HARD}}},
   146  						}
   147  						pcl33 := sup.PCL(33)
   148  						td := do(pcl33, cgIdx)
   149  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{submitted: pcl33.GetDeps()})
   150  						So(td.OK(), ShouldBeTrue)
   151  					})
   152  
   153  					Convey("Wrong config group", func() {
   154  						sup.pb.Pcls = []*prjpb.PCL{
   155  							{Clid: 31, Triggers: dryRun(epoch.Add(3 * time.Second)), ConfigGroupIndexes: []int32{anotherIdx}},
   156  							{Clid: 32, Triggers: dryRun(epoch.Add(2 * time.Second)), ConfigGroupIndexes: []int32{anotherIdx, cgIdx}},
   157  							{Clid: 33, Triggers: dryRun(epoch.Add(1 * time.Second)), ConfigGroupIndexes: []int32{cgIdx},
   158  								Deps: []*changelist.Dep{
   159  									{Clid: 31, Kind: changelist.DepKind_SOFT},
   160  									{Clid: 32, Kind: changelist.DepKind_HARD},
   161  								}},
   162  						}
   163  						pcl33 := sup.PCL(33)
   164  						td := do(pcl33, cgIdx)
   165  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   166  							lastCQVoteTriggered: epoch.Add(3 * time.Second),
   167  							invalidDeps: &changelist.CLError_InvalidDeps{
   168  								WrongConfigGroup: pcl33.GetDeps(),
   169  							},
   170  						})
   171  						So(td.OK(), ShouldBeFalse)
   172  					})
   173  
   174  					Convey("Too many deps", func() {
   175  						// Create maxAllowedDeps+1 deps.
   176  						sup.pb.Pcls = make([]*prjpb.PCL, 0, maxAllowedDeps+2)
   177  						deps := make([]*changelist.Dep, 0, maxAllowedDeps+1)
   178  						for i := 1; i <= maxAllowedDeps+1; i++ {
   179  							sup.pb.Pcls = append(sup.pb.Pcls, &prjpb.PCL{
   180  								Clid:               int64(1000 + i),
   181  								ConfigGroupIndexes: []int32{cgIdx},
   182  								Triggers:           dryRun(epoch.Add(time.Second)),
   183  							})
   184  							deps = append(deps, &changelist.Dep{Clid: int64(1000 + i), Kind: changelist.DepKind_SOFT})
   185  						}
   186  						// Add the PCL with the above deps.
   187  						sup.pb.Pcls = append(sup.pb.Pcls, &prjpb.PCL{
   188  							Clid:               2000,
   189  							ConfigGroupIndexes: []int32{cgIdx},
   190  							Triggers:           dryRun(epoch.Add(time.Second)),
   191  							Deps:               deps,
   192  						})
   193  						td := do(sup.PCL(2000), cgIdx)
   194  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   195  							lastCQVoteTriggered: epoch.Add(time.Second),
   196  							invalidDeps: &changelist.CLError_InvalidDeps{
   197  								TooMany: &changelist.CLError_InvalidDeps_TooMany{
   198  									Actual:     maxAllowedDeps + 1,
   199  									MaxAllowed: maxAllowedDeps,
   200  								},
   201  							},
   202  						})
   203  						So(td.OK(), ShouldBeFalse)
   204  					})
   205  				})
   206  			}
   207  			sameTests("singular", singIdx)
   208  			sameTests("combinable", combIdx)
   209  		})
   210  
   211  		Convey("Singular speciality", func() {
   212  			sup.pb.Pcls = []*prjpb.PCL{
   213  				{
   214  					Clid: 31, ConfigGroupIndexes: []int32{singIdx},
   215  					Triggers: dryRun(epoch.Add(3 * time.Second)),
   216  				},
   217  				{
   218  					Clid: 32, ConfigGroupIndexes: []int32{singIdx},
   219  					Triggers: fullRun(epoch.Add(2 * time.Second)), // not happy about its dep.
   220  					Deps:     []*changelist.Dep{{Clid: 31, Kind: changelist.DepKind_HARD}},
   221  				},
   222  				{
   223  					Clid: 33, ConfigGroupIndexes: []int32{singIdx},
   224  					Triggers: dryRun(epoch.Add(3 * time.Second)), // doesn't care about deps.
   225  					Deps: []*changelist.Dep{
   226  						{Clid: 31, Kind: changelist.DepKind_SOFT},
   227  						{Clid: 32, Kind: changelist.DepKind_HARD},
   228  					},
   229  				},
   230  			}
   231  			Convey("dry run doesn't care about deps' triggers", func() {
   232  				pcl33 := sup.PCL(33)
   233  				td := do(pcl33, singIdx)
   234  				So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   235  					lastCQVoteTriggered: epoch.Add(3 * time.Second),
   236  				})
   237  			})
   238  			Convey("full run doesn't allow any dep by default", func() {
   239  				pcl32 := sup.PCL(32)
   240  				td := do(pcl32, singIdx)
   241  				So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   242  					lastCQVoteTriggered: epoch.Add(3 * time.Second),
   243  					invalidDeps: &changelist.CLError_InvalidDeps{
   244  						SingleFullDeps: pcl32.GetDeps(),
   245  					},
   246  				})
   247  				So(td.OK(), ShouldBeFalse)
   248  
   249  				Convey("unless allow_submit_with_open_deps is true", func() {
   250  					sup.cgs[singIdx].Content.Verifiers = &cfgpb.Verifiers{
   251  						GerritCqAbility: &cfgpb.Verifiers_GerritCQAbility{
   252  							AllowSubmitWithOpenDeps: true,
   253  						},
   254  					}
   255  					td := do(pcl32, singIdx)
   256  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   257  						lastCQVoteTriggered: epoch.Add(3 * time.Second),
   258  					})
   259  					So(td.OK(), ShouldBeTrue)
   260  
   261  					Convey("but not if dep is soft", func() {
   262  						// Soft dependency (ie via Cq-Depend) won't be submitted as part a
   263  						// single Submit gerrit RPC, so it can't be allowed.
   264  						pcl32.GetDeps()[0].Kind = changelist.DepKind_SOFT
   265  						td := do(pcl32, singIdx)
   266  						So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   267  							lastCQVoteTriggered: epoch.Add(3 * time.Second),
   268  							invalidDeps: &changelist.CLError_InvalidDeps{
   269  								SingleFullDeps: pcl32.GetDeps(),
   270  							},
   271  						})
   272  						So(td.OK(), ShouldBeFalse)
   273  					})
   274  				})
   275  
   276  				Convey("unless the user is an MCE dogfooder and deps are HARD", func() {
   277  					// TODO(crbug/1470341) remove this test if chained cq votes
   278  					// is enabled by default.
   279  					pcl31 := sup.PCL(31)
   280  					pcl31.Triggers = nil
   281  					pcl32 := sup.PCL(32)
   282  					pcl32.Triggers.CqVoteTrigger.Email = "test@example.org"
   283  					ct.AddMember("test@example.org", common.MCEDogfooderGroup)
   284  
   285  					// triage with HARD dep. It should be good.
   286  					td := do(pcl32, singIdx)
   287  					So(td.OK(), ShouldBeTrue)
   288  					// triage with a SOFT dep. This should fail, as chained cq
   289  					// votes only support HARD deps.
   290  					pcl32.GetDeps()[0].Kind = changelist.DepKind_SOFT
   291  					td = do(pcl32, singIdx)
   292  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   293  						invalidDeps: &changelist.CLError_InvalidDeps{
   294  							SingleFullDeps: pcl32.GetDeps(),
   295  						},
   296  					})
   297  					So(td.OK(), ShouldBeFalse)
   298  				})
   299  			})
   300  		})
   301  
   302  		Convey("Full run with chained CQ votes", func() {
   303  			voter := "test@example.org"
   304  			ct.AddMember(voter, common.MCEDogfooderGroup)
   305  			sup.pb.Pcls = []*prjpb.PCL{
   306  				{
   307  					Clid: 31, ConfigGroupIndexes: []int32{singIdx},
   308  				},
   309  				{
   310  					Clid: 32, ConfigGroupIndexes: []int32{singIdx},
   311  					Deps: []*changelist.Dep{
   312  						{Clid: 31, Kind: changelist.DepKind_HARD},
   313  					},
   314  				},
   315  				{
   316  					Clid: 33, ConfigGroupIndexes: []int32{singIdx},
   317  					Deps: []*changelist.Dep{
   318  						{Clid: 31, Kind: changelist.DepKind_HARD},
   319  						{Clid: 32, Kind: changelist.DepKind_HARD},
   320  					},
   321  				},
   322  			}
   323  
   324  			Convey("Single vote on the topmost CL", func() {
   325  				pcl33 := sup.PCL(33)
   326  				pcl33.Triggers = fullRun(epoch)
   327  				pcl33.Triggers.CqVoteTrigger.Email = voter
   328  				td := do(pcl33, singIdx)
   329  
   330  				// The triage dep result should be OK(), but have
   331  				// the not-yet-voted deps in needToTrigger
   332  				So(td.OK(), ShouldBeTrue)
   333  				So(td.needToTrigger, ShouldResembleProto, []*changelist.Dep{
   334  					{Clid: 31, Kind: changelist.DepKind_HARD},
   335  					{Clid: 32, Kind: changelist.DepKind_HARD},
   336  				})
   337  			})
   338  			Convey("a dep already has CQ+2", func() {
   339  				pcl31 := sup.PCL(31)
   340  				pcl31.Triggers = fullRun(epoch)
   341  				pcl31.Triggers.CqVoteTrigger.Email = voter
   342  				pcl33 := sup.PCL(33)
   343  				pcl33.Triggers = fullRun(epoch)
   344  				pcl33.Triggers.CqVoteTrigger.Email = voter
   345  				td := do(pcl33, singIdx)
   346  
   347  				So(td.OK(), ShouldBeTrue)
   348  				So(td.needToTrigger, ShouldResembleProto, []*changelist.Dep{
   349  					{Clid: 32, Kind: changelist.DepKind_HARD},
   350  				})
   351  			})
   352  			Convey("a dep has CQ+1", func() {
   353  				pcl31 := sup.PCL(31)
   354  				pcl31.Triggers = dryRun(epoch)
   355  				pcl31.Triggers.CqVoteTrigger.Email = voter
   356  				pcl33 := sup.PCL(33)
   357  				pcl33.Triggers = fullRun(epoch)
   358  				pcl33.Triggers.CqVoteTrigger.Email = voter
   359  
   360  				// triageDep should still put the dep with CQ+1 in
   361  				// needToTrigger, so that PM will schedule a TQ task to override
   362  				// the CQ vote with CQ+2.
   363  				td := do(pcl33, singIdx)
   364  				So(td.OK(), ShouldBeTrue)
   365  				So(td.needToTrigger, ShouldResembleProto, []*changelist.Dep{
   366  					{Clid: 31, Kind: changelist.DepKind_HARD},
   367  					{Clid: 32, Kind: changelist.DepKind_HARD},
   368  				})
   369  			})
   370  		})
   371  		Convey("Combinable speciality", func() {
   372  			// Setup valid deps; sub-tests wll mutate this to become invalid.
   373  			sup.pb.Pcls = []*prjpb.PCL{
   374  				{
   375  					Clid: 31, ConfigGroupIndexes: []int32{combIdx},
   376  					Triggers: dryRun(epoch.Add(3 * time.Second)),
   377  				},
   378  				{
   379  					Clid: 32, ConfigGroupIndexes: []int32{combIdx},
   380  					Triggers: dryRun(epoch.Add(2 * time.Second)),
   381  					Deps:     []*changelist.Dep{{Clid: 31, Kind: changelist.DepKind_HARD}},
   382  				},
   383  				{
   384  					Clid: 33, ConfigGroupIndexes: []int32{combIdx},
   385  					Triggers: dryRun(epoch.Add(1 * time.Second)),
   386  					Deps: []*changelist.Dep{
   387  						{Clid: 31, Kind: changelist.DepKind_SOFT},
   388  						{Clid: 32, Kind: changelist.DepKind_HARD},
   389  					},
   390  				},
   391  			}
   392  			Convey("dry run expects all deps to be dry", func() {
   393  				pcl32 := sup.PCL(32)
   394  				Convey("ok", func() {
   395  					td := do(pcl32, combIdx)
   396  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{lastCQVoteTriggered: epoch.Add(3 * time.Second)})
   397  				})
   398  
   399  				Convey("... not full runs", func() {
   400  					// TODO(tandrii): this can and should be supported.
   401  					sup.PCL(31).Triggers.CqVoteTrigger.Mode = string(run.FullRun)
   402  					td := do(pcl32, combIdx)
   403  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   404  						lastCQVoteTriggered: epoch.Add(3 * time.Second),
   405  						invalidDeps: &changelist.CLError_InvalidDeps{
   406  							CombinableMismatchedMode: pcl32.GetDeps(),
   407  						},
   408  					})
   409  				})
   410  			})
   411  			Convey("full run considers any dep incompatible", func() {
   412  				pcl33 := sup.PCL(33)
   413  				Convey("ok", func() {
   414  					for _, pcl := range sup.pb.GetPcls() {
   415  						pcl.Triggers.CqVoteTrigger.Mode = string(run.FullRun)
   416  					}
   417  					td := do(pcl33, combIdx)
   418  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{lastCQVoteTriggered: epoch.Add(3 * time.Second)})
   419  				})
   420  				Convey("... not dry runs", func() {
   421  					sup.PCL(32).Triggers.CqVoteTrigger.Mode = string(run.FullRun)
   422  					td := do(pcl33, combIdx)
   423  					So(td, cvtesting.SafeShouldResemble, &triagedDeps{
   424  						lastCQVoteTriggered: epoch.Add(3 * time.Second),
   425  						invalidDeps: &changelist.CLError_InvalidDeps{
   426  							CombinableMismatchedMode: []*changelist.Dep{{Clid: 32, Kind: changelist.DepKind_HARD}},
   427  						},
   428  					})
   429  					So(td.OK(), ShouldBeFalse)
   430  				})
   431  			})
   432  		})
   433  
   434  		Convey("iterateNotSubmitted works", func() {
   435  			d1 := &changelist.Dep{Clid: 1}
   436  			d2 := &changelist.Dep{Clid: 2}
   437  			d3 := &changelist.Dep{Clid: 3}
   438  			pcl := &prjpb.PCL{}
   439  			td := &triagedDeps{}
   440  
   441  			iterate := func() (out []*changelist.Dep) {
   442  				td.iterateNotSubmitted(pcl, func(dep *changelist.Dep) { out = append(out, dep) })
   443  				return
   444  			}
   445  
   446  			Convey("no deps", func() {
   447  				So(iterate(), ShouldBeEmpty)
   448  			})
   449  			Convey("only submitted", func() {
   450  				td.submitted = []*changelist.Dep{d3, d1, d2}
   451  				pcl.Deps = []*changelist.Dep{d3, d1, d2} // order must be the same
   452  				So(iterate(), ShouldBeEmpty)
   453  			})
   454  			Convey("some submitted", func() {
   455  				pcl.Deps = []*changelist.Dep{d3, d1, d2}
   456  				td.submitted = []*changelist.Dep{d3}
   457  				So(iterate(), ShouldResembleProto, []*changelist.Dep{d1, d2})
   458  				td.submitted = []*changelist.Dep{d1}
   459  				So(iterate(), ShouldResembleProto, []*changelist.Dep{d3, d2})
   460  				td.submitted = []*changelist.Dep{d2}
   461  				So(iterate(), ShouldResembleProto, []*changelist.Dep{d3, d1})
   462  			})
   463  			Convey("none submitted", func() {
   464  				pcl.Deps = []*changelist.Dep{d3, d1, d2}
   465  				So(iterate(), ShouldResembleProto, []*changelist.Dep{d3, d1, d2})
   466  			})
   467  			Convey("notYetLoaded deps are iterated over, too", func() {
   468  				pcl.Deps = []*changelist.Dep{d3, d1, d2}
   469  				td.notYetLoaded = []*changelist.Dep{d3}
   470  				td.submitted = []*changelist.Dep{d2}
   471  				So(iterate(), ShouldResembleProto, []*changelist.Dep{d3, d1})
   472  			})
   473  			Convey("panic on invalid usage", func() {
   474  				Convey("wrong PCL", func() {
   475  					pcl.Deps = []*changelist.Dep{d3, d1, d2}
   476  					td.submitted = []*changelist.Dep{d1, d2, d3} // wrong order
   477  					So(func() { iterate() }, ShouldPanicLike, fmt.Errorf("(wrong PCL?)"))
   478  				})
   479  			})
   480  		})
   481  	})
   482  }