Learning
A late-submission test needs setup and policy to agree about time. This example shares a fake clock between them and explores why a nullable grade of zero must survive a derived default.
School → Course → Section → Enrollment → Assignment → Submission. Rosters run across a course's sections, assignments restart per enrollment, and a late policy reads the same FakeTimeProvider as the setup. Source: tests/Zeeq.Biome.Tests/Learning.
Blueprints: scopes, time, and a derived Late flag
// Enrollment parents are sections; Per<Course>
// continues the roster across those sections.
internal static readonly Sequence<int> RosterNumbers = Sequence
.Of(1, n => n + 1)
.Per<Course>();
// Open in the past, due in the future; use ctx time for setup instead of UtcNow.
internal static readonly ChildBlueprint<
LearningSeed,
Enrollment,
Assignment
> Assignment = Blueprint
.ChildOf<LearningSeed, Enrollment, Assignment>(
(enrollment, ctx) =>
new()
{
Enrollment = enrollment,
School = ctx.Ancestor<School>(),
OpensAt = ctx.Ago(TimeSpan.FromDays(1)),
DueAt = ctx.FromNow(TimeSpan.FromDays(1)),
}
)
.Derive(a => a.Number, AssignmentNumbers)
.Derive(a => a.Title, (a, _) => $"Assignment {a.Number}");
// Time precedes the Late derive; grade remains
// fill-only so a call-site grade survives.
internal static readonly ChildBlueprint<
LearningSeed,
Assignment,
Submission
> Submission = Blueprint
.ChildOf<LearningSeed, Assignment, Submission>(
(assignment, ctx) =>
new()
{
Assignment = assignment,
School = ctx.Ancestor<School>(),
SubmittedAt = ctx.Now,
}
)
.Derive(s => s.Attempt, Attempts)
.Derive(s => s.Grade, (_, _) => 80m)
.Derive(s => s.Late, (s, _) => s.SubmittedAt > s.Assignment.DueAt);
A call-site grade survives the fill-only derive
A grade of zero is a meaningful result, so this model uses decimal? to distinguish it from a missing grade. The test checks that the derived default leaves zero alone and that a draft can be built without being saved.
await biome
.AddSchool(out _)
.AddCourse(out var course)
.AddSections(out var sections, _ => { }, s => s.Number = 1)
.AddEnrollments(out _, 2)
.AddAssignments(out var assignments, 1)
.AddSubmissions(
out var submissions,
Submissions.Distinction + Submissions.Reviewed
)
.Under(course)
.AddSection(out var third)
// Return to a specific assignment for a second, preview-only attempt.
.Under(assignments[0])
.AddSubmission(out var draft, s => s.Grade = 0m)
.Transient()
.BuildAsync();
var school = new School { District = "Example district" };
var course = new Course { School = school };
var sectionTwo = new Section
{
Course = course,
Number = 2,
Name = "Section 2",
};
var sectionOne = new Section
{
Course = course,
Number = 1,
Name = "Section 1",
}; // explicit 1 pushes the first to 2
var sections = new[] { sectionTwo, sectionOne };
var third = new Section
{
Course = course,
Number = 3,
Name = "Section 3",
};
var now = DateTimeOffset.UtcNow;
var roster = 1; // per course, across sections
var enrollments = sections
.SelectMany(s =>
Enumerable
.Range(0, 2)
.Select(_ => new Enrollment
{
Section = s,
RosterNumber = roster,
Student = $"Student {roster++}",
})
)
.ToList();
var assignments = enrollments
.Select(e => new Assignment
{
Enrollment = e,
School = school,
Number = 1,
Title = "Assignment 1",
OpensAt = now.AddDays(-1),
DueAt = now.AddDays(1),
})
.ToList();
var submissions = assignments
.Select(a => new Submission
{
Assignment = a,
School = school,
Attempt = 1,
Grade = 95m,
Reviewed = true,
SubmittedAt = now,
Late = false,
})
.ToList();
var draft = new Submission
{
Assignment = assignments[0],
School = school,
Attempt = 2,
Grade = 0m,
SubmittedAt = now,
}; // not saved
db.AddRange(school, course, sectionTwo, sectionOne, third);
db.AddRange(enrollments);
db.AddRange(assignments);
db.AddRange(submissions);
await db.SaveChangesAsync();
The real test asserts sections[0].Number == 2, third.Number == 3, draft.Grade == 0m (explicit zero kept, not filled with 80), draft.Attempt == 2 (attempts resume per assignment), and four saved submissions out of five built.
Derive(s => s.Grade, (_, _) => 80m) fills decimal? only when it's null. Because Grade is nullable, 0m is explicit. For a non-nullable decimal, 0 would count as unset.Roster numbers cross section boundaries
await biome
.AddSchools(out var schools, 2)
.AddCourses(out _, 2) // 4 courses
.AddSections(out var sections, 2) // 8 sections
.AddEnrollments(out var enrollments, 3) // 24 enrollments
.AddAssignments(out var assignments, 2) // 48 assignments
.AddSubmissions(out var submissions, 1)
.BuildAsync();
// sections[2].Number == 1 (numbered per course)
// enrollments[3].RosterNumber == 4 (the roster continues into the next section)
// enrollments[6].RosterNumber == 1 (a new course restarts it)
// submissions[47].School == schools[1] (Ancestor<School> reaches five levels up)
One clock for setup and policy
The late policy and the biome read the same fake clock. Advancing it changes the policy's answer, but never mutates historical rows:
var deadline = new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
var time = new FakeTimeProvider(deadline.AddHours(-1));
var policy = new LatePolicy(time);
var biome = TestBiome.Start(out _, time: time);
biome.AddLateSubmission(
out var historicalLate,
dueAt: time.GetUtcNow().AddMinutes(-1)
);
biome
.AddSchool(out _)
.AddCourse(out _)
.AddSection(out _)
.AddEnrollment(out _)
.AddAssignment(out var upcoming, a => a.DueAt = deadline);
var wasOverdue = policy.IsOverdue(upcoming); // false
time.Advance(TimeSpan.FromHours(2));
await biome.Under(upcoming).AddSubmission(out var late).BuildAsync();
// policy.IsOverdue(upcoming) is now true; late.Late is derived true;
// historicalLate.SubmittedAt is unchanged
AddLateSubmission(out var leaf, dueAt: …) builds the whole six-level branch with a chosen deadline, and its definition still shows every step. See Scenario helpers.