Sequences
Why not index + 1
Numbering from ctx.Index + 1 looks reasonable while a test has one Add. Add another batch, though, and the index starts over. In zeeq-app tests, that produced duplicate "PR #1" rows and a unique-constraint failure.
A sequence addresses that by keeping its position across Adds. Declare a start value and a next function once, then choose where the counter should be shared. Its state lives in each session, so one test's numbering does not affect another.
Declare, scope, derive
public static partial class Blueprints
{
public static readonly Sequence<int> PullRequestNumbers = Sequence
.Of(1, n => n + 1)
.Per<CodeRepository>(); // numbering restarts per repository
public static readonly ChildBlueprint<
ZeeqSeed,
CodeRepository,
PullRequestRecord
> PullRequest = Blueprint
.ChildOf<ZeeqSeed, CodeRepository, PullRequestRecord>(
(repo, ctx) =>
new()
{
Id = ctx.NewId("pr"),
OrganizationId = repo.OrganizationId,
RepositoryId = repo.Id,
State = PullRequestState.Open,
CreatedAtUtc = ctx.Now,
}
)
.Derive(pr => pr.PullRequestNumber, PullRequestNumbers)
// runs after the number
.Derive(pr => pr.Title, (pr, _) => $"Pull request {pr.PullRequestNumber}")
.Derive(pr => pr.Branch, (pr, _) => $"feature/{pr.PullRequestNumber}")
// globally unique
.Derive(pr => pr.GitHubNodeId, (_, ctx) => ctx.NewId("PR"));
}
Assign it with the fill-only Derive(property, sequence).
Scope: one counter per…
| Scope | One counter per | Example |
|---|---|---|
.PerSession() (default) | test | anything unique across the whole test |
.PerParent() | immediate parent | card rank within a column |
.Per<TAncestor>() | nearest ancestor of that type | PR number within a repository |
State is keyed by (sequence instance, scope entity identity), not by blueprint and not by anchor position. So blueprint variants and .Under(repo) resume the same progression.
await TestBiome
.Start(out _)
.AddCodeRepositories(out var repos, 2)
.AddPullRequests(out var first, 2) // 1, 2 | 1, 2
.Under(repos[0])
.AddPullRequest(out var third) // 3: resumes repos[0]
.Under(repos[0])
.AddPullRequests(
out var mixed,
_ => { }, // 5 (4 is taken below)
pr => pr.PullRequestNumber = 4
) // explicit: kept, reserved
.BuildAsync();
// Hand numbering: the counter has to be threaded through every helper call,
// and an explicit number has to be avoided by hand.
var repos = new[] { NewRepository(), NewRepository() };
var next = new Dictionary<CodeRepository, int> { [repos[0]] = 1, [repos[1]] = 1 };
PullRequestRecord NewPr(CodeRepository repo, int? number = null)
{
var n = number ?? next[repo]++;
return new PullRequestRecord
{
Id = $"pr_{Guid.CreateVersion7():N}",
OrganizationId = repo.OrganizationId,
RepositoryId = repo.Id,
PullRequestNumber = n,
Title = $"Pull request {n}",
Branch = $"feature/{n}",
State = PullRequestState.Open,
};
}
var first = new[]
{
NewPr(repos[0]),
NewPr(repos[0]),
NewPr(repos[1]),
NewPr(repos[1]),
}; // 1, 2 | 1, 2
var third = NewPr(repos[0]); // 3
var explicit4 = NewPr(repos[0], number: 4);
next[repos[0]] = 5; // skip 4 by hand
var fifth = NewPr(repos[0]); // 5
Explicit values win
Sometimes the number itself matters to the test. The sequence needs to leave that value available while assigning the surrounding defaults. Biome reserves explicit values before filling the unset ones:
- Explicit values never move the sequence. And the sequence skips any value already set explicitly in that scope, in this step or an earlier one.
- Within one step, Biome first reserves every explicit value, then fills the rest. The test below shows
[2, 1, 3, 4]: the first unset entry takes2because1and3are explicit.
await TestBiome
.Start(out _)
.AddCodeRepository(out var repo)
.AddPullRequests(
out var prs,
_ => { },
pr => pr.Number = 1,
pr => pr.Number = 3,
_ => { }
)
.Under(repo)
.AddPullRequest(out var next)
.BuildAsync();
// prs numbers: 2, 1, 3, 4; next.Number: 5
An explicit high value doesn't advance the counter either:
await TestBiome
.Start(out _)
.AddCodeRepository(out var repo)
.AddPullRequest(
out var explicitPr,
pr =>
{
pr.Number = 99;
pr.Title = "Regression";
}
)
.Under(repo)
// Number == 1; the explicit 99 did not move the sequence
.AddPullRequest(out var generated)
.BuildAsync();
Prefer Derive(prop, sequence) over ctx.Next
ctx.Next(sequence) cannot see values the test will supply. That can create a collision. Prefer .Derive(property, sequence), which reserves explicit values before assigning defaults.Rules of thumb
- Declare the sequence above the blueprints that use it. In a static class, fields initialize top to bottom. Keep a base blueprint and its variants in the same file; across
partialfiles the initialization order is unspecified. Derives run in declaration order, so deriveTitleafter the number it depends on.- Variants made with
.Withshare their base blueprint's sequence. - Globally unique values (node IDs, slugs, emails) use a
PerSessionsequence orctx.NewId, neverPerParent/Per<T>: two parents would both producePR_1. - The
nextfunction may be any type. Ranks work too, as in the Board tests below. - A
nextfunction that cycles through reserved values throws, rather than hanging.
Non-numeric sequences: ranks
// PerParent resumes for the same column reference;
// it restarts for a different column.
internal static readonly Sequence<string> Ranks = Sequence
.Of("a", rank => rank + "a")
.PerParent();
internal static readonly ChildBlueprint<BoardSeed, Column, Card> Card =
Blueprint
.ChildOf<BoardSeed, Column, Card>(
(column, _) => new() { Column = column }
)
.Derive(c => c.Rank, Ranks);
Rank here is null until the Derive runs. An empty string would count as explicitly set and the sequence would never fill it.await TestBiome
.Start(out _)
.AddBoard(out var board)
.AddColumns(out var columns, 2)
.AddCards(out var cards, 2) // "a", "aa" | "a", "aa"
.Under(columns[0])
.AddCard(out var next) // "aaa": resumes only this column
.Under(board)
.AddColumn(out var sibling)
.AddCard(out var siblingCard) // "a": a new column restarts
.BuildAsync();