Skip to content

Commit 7658425

Browse files
author
Jani Giannoudis
committed
fix FindMatchingPeriodValue: add left-contains-right branch for retro payrun (NovaTechRetro bug)
1 parent 446031d commit 7658425

4 files changed

Lines changed: 170 additions & 6 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* CasePayrollValueTests */
2+
3+
using System;
4+
using Xunit;
5+
6+
namespace PayrollEngine.Client.Scripting.Tests;
7+
8+
/// <summary>
9+
/// Tests for CasePayrollValue binary operators, focusing on
10+
/// FindMatchingPeriodValue period-matching behaviour.
11+
///
12+
/// NovaTechRetro bug:
13+
/// In a Retro-Payrun the left PeriodValue carries an open-ended Period
14+
/// (End = Date.MaxValue), while the right PeriodValue carries a trimmed
15+
/// CalendarPeriod (End = last hour of the month).
16+
/// The original containment check only tested whether the RIGHT period
17+
/// contains the LEFT period — the inverse case (open LEFT contains trimmed
18+
/// RIGHT) was not handled, so FindMatchingPeriodValue returned null,
19+
/// the operator loop silently skipped every period, and the Payrun
20+
/// failed with "Missing results".
21+
/// </summary>
22+
public class CasePayrollValueTests
23+
{
24+
// ── Shared period fixtures ──────────────────────────────────────────────
25+
26+
/// <summary>Standard CalendarPeriod: January 2024, trimmed end (last full hour)</summary>
27+
private static readonly DateTime Jan2024Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
28+
private static readonly DateTime Jan2024End = new(2024, 1, 31, 23, 0, 0, DateTimeKind.Utc);
29+
30+
// ── 1. Exact match ──────────────────────────────────────────────────────
31+
32+
[Fact(DisplayName = "Operator + exact period match returns correct sum")]
33+
public void Add_ExactPeriodMatch_ReturnsSum()
34+
{
35+
// Both sides carry identical CalendarPeriods → exact Equals() match.
36+
var left = MakeCaseValue("Salary", Jan2024Start, Jan2024End, 3000M);
37+
var right = MakeCaseValue("Bonus", Jan2024Start, Jan2024End, 500M);
38+
39+
decimal result = left + right;
40+
41+
Assert.Equal(3500M, result);
42+
}
43+
44+
// ── 2. Normal containment (right contains left) ─────────────────────────
45+
46+
[Fact(DisplayName = "Operator + right-contains-left: open right covers trimmed left")]
47+
public void Add_RightContainsLeft_ReturnsSum()
48+
{
49+
// left = trimmed CalendarPeriod (End = Jan2024End)
50+
// right = open-ended Period (End = Date.MaxValue)
51+
// The existing containment branch handles this case.
52+
var left = MakeCaseValue("Salary", Jan2024Start, Jan2024End, 3000M);
53+
var right = MakeCaseValue("Salary", Jan2024Start, null, 500M); // open-ended
54+
55+
decimal result = left + right;
56+
57+
Assert.Equal(3500M, result);
58+
}
59+
60+
// ── 3. NovaTechRetro bug – left contains right ──────────────────────────
61+
62+
[Fact(DisplayName = "NovaTechRetro bug: Operator + open left vs. trimmed right returns correct sum")]
63+
public void Add_OpenLeftVsTrimmedRight_RetroScenario_ReturnsSum()
64+
{
65+
// This is the exact scenario that caused the NovaTechRetro failure.
66+
//
67+
// In a Retro-Payrun, the current-period case value is delivered with an
68+
// open-ended Period (End = Date.MaxValue), while the previously calculated
69+
// value still carries the trimmed CalendarPeriod end.
70+
//
71+
// left = open-ended Period (End = Date.MaxValue) ← Retro-Payrun result
72+
// right = CalendarPeriod (End = Jan2024End) ← prior calculation
73+
//
74+
// Without the fix: FindMatchingPeriodValue returns null → operator skips
75+
// the period → result stays Empty → PayrollException: Missing results.
76+
// With the fix: left-contains-right branch matches → result = 3000 + 100 = 3100.
77+
78+
var left = MakeCaseValue("Salary", Jan2024Start, null, 3000M); // open-ended
79+
var right = MakeCaseValue("Salary", Jan2024Start, Jan2024End, 100M); // CalendarPeriod
80+
81+
decimal result = left + right;
82+
83+
Assert.Equal(3100M, result);
84+
}
85+
86+
[Fact(DisplayName = "NovaTechRetro bug: Operator - open left vs. trimmed right returns correct difference")]
87+
public void Subtract_OpenLeftVsTrimmedRight_RetroScenario_ReturnsDifference()
88+
{
89+
// Subtraction variant: retro diff = current salary – previously calculated salary.
90+
var left = MakeCaseValue("Salary", Jan2024Start, null, 3000M);
91+
var right = MakeCaseValue("Salary", Jan2024Start, Jan2024End, 2800M);
92+
93+
decimal result = left - right;
94+
95+
Assert.Equal(200M, result);
96+
}
97+
98+
[Fact(DisplayName = "NovaTechRetro bug: Operator * open left vs. trimmed right returns correct product")]
99+
public void Multiply_OpenLeftVsTrimmedRight_RetroScenario_ReturnsProduct()
100+
{
101+
var left = MakeCaseValue("Rate", Jan2024Start, null, 3000M);
102+
var right = MakeCaseValue("Factor", Jan2024Start, Jan2024End, 0.1M);
103+
104+
decimal result = left * right;
105+
106+
Assert.Equal(300M, result);
107+
}
108+
109+
[Fact(DisplayName = "NovaTechRetro bug: Operator / open left vs. trimmed right returns correct quotient")]
110+
public void Divide_OpenLeftVsTrimmedRight_RetroScenario_ReturnsQuotient()
111+
{
112+
var left = MakeCaseValue("Salary", Jan2024Start, null, 3000M);
113+
var right = MakeCaseValue("Divisor", Jan2024Start, Jan2024End, 3M);
114+
115+
decimal result = left / right;
116+
117+
Assert.Equal(1000M, result);
118+
}
119+
120+
// ── 4. No-match → empty result ──────────────────────────────────────────
121+
122+
[Fact(DisplayName = "Operator + non-overlapping periods returns empty")]
123+
public void Add_NonOverlappingPeriods_ReturnsEmpty()
124+
{
125+
// Completely different periods → no match expected from any branch.
126+
var feb2024Start = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc);
127+
var feb2024End = new DateTime(2024, 2, 29, 23, 0, 0, DateTimeKind.Utc);
128+
129+
var left = MakeCaseValue("Salary", Jan2024Start, Jan2024End, 3000M);
130+
var right = MakeCaseValue("Salary", feb2024Start, feb2024End, 500M);
131+
132+
var result = left + right;
133+
134+
Assert.False(result.HasValue);
135+
}
136+
137+
// ── Helper ──────────────────────────────────────────────────────────────
138+
139+
/// <summary>
140+
/// Creates a CasePayrollValue with a single PeriodValue.
141+
/// When <paramref name="end"/> is null the period is open-ended (End = Date.MaxValue).
142+
/// </summary>
143+
private static CasePayrollValue MakeCaseValue(
144+
string caseFieldName, DateTime start, DateTime? end, decimal value)
145+
{
146+
var periodValue = new PeriodValue(start, end, value);
147+
return new CasePayrollValue(caseFieldName, [periodValue]);
148+
}
149+
}

Client.Scripting/CasePayrollValue.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,25 @@ public static implicit operator DateTime(CasePayrollValue payrollValue) =>
210210
#region Binary operators
211211

212212
/// <summary>Find the best matching period value for a given left period.
213-
/// First tries exact match; falls back to containment (right period contains left period).
214-
/// Required when TimeType differs, e.g. CalendarPeriod (trimmed) vs. Period (open-ended, End=MaxValue).</summary>
213+
/// Tries in order: (1) exact match, (2) right contains left, (3) left contains right.
214+
/// The third branch is required for Retro-Payruns where the left side carries an open-ended
215+
/// Period (End=MaxValue) and the right side carries a trimmed CalendarPeriod end.
216+
/// Without it, the containment check "trimmedEnd >= MaxValue" silently returns null,
217+
/// causing all binary operators to return Empty and the payrun to fail with Missing results.
218+
/// </summary>
215219
private static PeriodValue FindMatchingPeriodValue(ReadOnlyCollection<PeriodValue> periodValues, DatePeriod leftPeriod)
216220
{
221+
// 1. exact match
217222
var match = periodValues.FirstOrDefault(x => Equals(x.Period, leftPeriod));
218223
if (match != null) return match;
219-
return periodValues.FirstOrDefault(x =>
224+
// 2. right contains left (open-ended right covers trimmed CalendarPeriod left)
225+
match = periodValues.FirstOrDefault(x =>
220226
x.Period.Start <= leftPeriod.Start && x.Period.End >= leftPeriod.End);
227+
if (match != null) return match;
228+
// 3. left contains right (open-ended left covers trimmed CalendarPeriod right)
229+
// NovaTechRetro bug: leftPeriod = open Period (End=MaxValue), x.Period = CalendarPeriod
230+
return periodValues.FirstOrDefault(x =>
231+
leftPeriod.Start <= x.Period.Start && leftPeriod.End >= x.Period.End);
221232
}
222233

223234
/// <summary>Addition of two case values</summary>

Client.Scripting/PayrollEngine.Client.Scripting.xml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,12 @@
351351
</member>
352352
<member name="M:PayrollEngine.Client.Scripting.CasePayrollValue.FindMatchingPeriodValue(System.Collections.ObjectModel.ReadOnlyCollection{PayrollEngine.Client.Scripting.PeriodValue},PayrollEngine.Client.Scripting.DatePeriod)">
353353
<summary>Find the best matching period value for a given left period.
354-
First tries exact match; falls back to containment (right period contains left period).
355-
Required when TimeType differs, e.g. CalendarPeriod (trimmed) vs. Period (open-ended, End=MaxValue).</summary>
354+
Tries in order: (1) exact match, (2) right contains left, (3) left contains right.
355+
The third branch is required for Retro-Payruns where the left side carries an open-ended
356+
Period (End=MaxValue) and the right side carries a trimmed CalendarPeriod end.
357+
Without it, the containment check "trimmedEnd >= MaxValue" silently returns null,
358+
causing all binary operators to return Empty and the payrun to fail with Missing results.
359+
</summary>
356360
</member>
357361
<member name="M:PayrollEngine.Client.Scripting.CasePayrollValue.op_Addition(PayrollEngine.Client.Scripting.CasePayrollValue,PayrollEngine.Client.Scripting.CasePayrollValue)">
358362
<summary>Addition of two case values</summary>

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<TargetFramework>net10.0</TargetFramework>
5-
<Version>0.10.0-beta.1</Version>
5+
<Version>0.10.0-beta.dev</Version>
66
<FileVersion>0.9.0</FileVersion>
77
<InformationalVersion></InformationalVersion>
88
<Authors>Jani Giannoudis</Authors>

0 commit comments

Comments
 (0)