From 9440ce13456bddfa61e8c445bc2c418a9b4fe4db Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 26 Aug 2026 18:13:38 +0200 Subject: [PATCH] fix(stamdata): evaluate the profession validity window per call, not at type-load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Professions.ByProgram was a `static readonly` field filtered on DateTime.Today, so it evaluated once when the type first loaded. Two consequences, both real: - A long-running process kept serving the answer it computed at startup. A mapping whose geldigVan fell after boot never appeared; one whose geldigTot passed never disappeared. - Both branches of StamdataFile.ActiveOn were unreachable from this caller, which is why this table's validity window had no test at all. It is the cleanest single explanation for Stamdata's 71.7% branch coverage (BL-005). Adds ByProgramOn(DateOnly) — the peildatum as an argument, matching StamdataTable.RowsOn which already parameterizes it — and makes ByProgram a property delegating to it with today's date. Call sites (DiplomaRules) are unchanged and keep the same behaviour, now with a current date. ProfessionsTests covers both ActiveOn branches plus the regression itself: the same date must give the same answer, a different date a different one. Found by the testability pass (TE-009). Co-Authored-By: Claude Opus 5 --- .../BigRegister.Api/Stamdata/Professions.cs | 26 +++++++--- .../Domain/ProfessionsTests.cs | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs diff --git a/backend/src/BigRegister.Api/Stamdata/Professions.cs b/backend/src/BigRegister.Api/Stamdata/Professions.cs index 285f2c9..aa83209 100644 --- a/backend/src/BigRegister.Api/Stamdata/Professions.cs +++ b/backend/src/BigRegister.Api/Stamdata/Professions.cs @@ -19,14 +19,26 @@ public static class Professions /// Every mapping in the data-file, typed. public static readonly IReadOnlyList Mappings = StamdataFile.Load("professions"); - /// The mappings valid today, as a program→beroep lookup. Consumers that don't - /// yet reason about a peildatum (e.g. DiplomaRules.ProfessionFor) use this — it - /// preserves the pre-valid-time behaviour exactly while the file's rows are all current. - public static readonly IReadOnlyDictionary ByProgram = - Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today))) + /// The mappings valid on , as a program→beroep lookup. + /// + /// Takes the peildatum as an argument rather than reading the clock. It used to be a + /// static readonly field filtered on DateTime.Today, which evaluated once at + /// type-load: a long-running process kept yesterday's answer across midnight, and a mapping + /// whose geldigVan fell after startup never appeared at all. It also made both + /// branches of permanently unreachable from here, which + /// is why this table's validity window was never exercised by a test. + public static IReadOnlyDictionary ByProgramOn(DateOnly on) => + Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)) .ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase); - /// Distinct professions, in declaration order — the list a user may declare - /// for a manual (unlisted) diploma. + /// The mappings valid today. Consumers that don't yet reason about a peildatum + /// (e.g. DiplomaRules.ProfessionFor) use this — same behaviour as before, but + /// evaluated per call so the date is current. + public static IReadOnlyDictionary ByProgram => ByProgramOn(Today()); + + /// Distinct professions valid today, in declaration order — the list a user may + /// declare for a manual (unlisted) diploma. public static IReadOnlyList All() => ByProgram.Values.Distinct().ToList(); + + private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today); } diff --git a/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs new file mode 100644 index 0000000..e0ddf82 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs @@ -0,0 +1,47 @@ +using BigRegister.Stamdata; + +namespace BigRegister.Tests.Domain; + +/// +/// The profession↔program map's validity window (TE-009). `ByProgram` used to be a +/// `static readonly` field filtered on `DateTime.Today` at type-load, so both branches of +/// `StamdataFile.ActiveOn` were unreachable from here and nothing asserted the window at all. +/// Now that the peildatum is a parameter, these are the two branches. +/// +public class ProfessionsTests +{ + [Fact] + public void A_mapping_is_absent_before_its_geldigVan() + { + // Every seeded row starts 2000-01-01; nothing is valid the day before. + Assert.Empty(Professions.ByProgramOn(new DateOnly(1999, 12, 31))); + } + + [Fact] + public void A_mapping_is_present_on_and_after_its_geldigVan() + { + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2000, 1, 1))["geneeskunde"]); + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2026, 8, 26))["geneeskunde"]); + } + + [Fact] + public void A_closed_mapping_is_absent_from_its_geldigTot_onwards() + { + // geldigTot is exclusive (`on < tot`), so the row drops out on the boundary date itself. + foreach (var m in Professions.Mappings.Where(m => m.GeldigTot is DateOnly)) + { + var tot = m.GeldigTot!.Value; + Assert.True(Professions.ByProgramOn(tot.AddDays(-1)).ContainsKey(m.Program)); + Assert.False(Professions.ByProgramOn(tot).ContainsKey(m.Program)); + } + } + + [Fact] + public void ByProgram_is_evaluated_per_call_not_captured_at_type_load() + { + // The regression this guards: a long-running process must not keep serving the answer + // it computed at startup. Same date in, same answer; different date in, different answer. + Assert.Equal(Professions.ByProgram.Count, Professions.ByProgramOn(DateOnly.FromDateTime(DateTime.Today)).Count); + Assert.NotEqual(Professions.ByProgram.Count, Professions.ByProgramOn(new DateOnly(1999, 12, 31)).Count); + } +}