Skip to content

Commit b962dfb

Browse files
authored
fix(gantt): cascaded issues and the critical path keep their working-day duration (#11065)
* fix(tracker): cascaded issues keep their working-day duration In working-days mode simulateCascade placed a shifted issue's anchor on a working day but moved its other end by the same number of calendar days, so the issue kept its calendar-day length and could end on a weekend or a holiday. A pushed successor and a pulled predecessor now keep their length in working days (workingDaySpan, never below 1); the free end is dueForSpan / startForSpan of the anchored end, for all four relation kinds. The anchor, the FS gap floor and the violation check are unchanged, and without a calendar the arithmetic is byte-identical. Test updates: the chain push from #11054 now ends B on Mon 19 (two working days) and C on Tue 20 - Wed 21; the multi-hop floor test uses a Monday primary so the floor still dominates the second hop; the pull test uses a two-working-day predecessor and keeps its expectation. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * fix(tracker): critical path preserves working-day durations computeCriticalPath preserved each issue's duration in milliseconds, so with a working-days calendar the latest start of a predecessor rolled back across a weekend was too early and its slack too large. The forward and backward passes now keep the working-day span (endFor / startFor via dueForSpan / startForSpan) when a calendar is active, so LS/LF match what the cascade would produce. Legacy mode keeps the millisecond duration; slack stays in milliseconds. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> * fix(tracker): address review — no silent truncation of long bars, critical-path coverage, doc placement Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> --------- Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com> Co-authored-by: Michael Uray <michaeluray@users.noreply.github.com>
1 parent 963b176 commit b962dfb

4 files changed

Lines changed: 380 additions & 35 deletions

File tree

‎plugins/tracker-resources/src/components/gantt/lib/__tests__/critical-path.test.ts‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,77 @@ describe('computeCriticalPath — working-days mode', () => {
196196
expect(res.violatedRelations.has(r1._id)).toBe(true)
197197
})
198198

199-
it('holiday between predecessor and successor produces a violation when succ is pinned tight', () => {
200-
// Tue is a holiday; FS lag=0 should require succ to start Wed, but succ is pinned to Tue.
199+
it.each([
200+
[
201+
'SS',
202+
'start-to-start',
203+
issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 22)),
204+
issue('B', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 22))
205+
],
206+
[
207+
'FF',
208+
'finish-to-finish',
209+
issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 22)),
210+
issue('B', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 22))
211+
],
212+
[
213+
'SF',
214+
'start-to-finish',
215+
issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 22)),
216+
issue('B', Date.UTC(2026, 4, 12), Date.UTC(2026, 4, 18))
217+
]
218+
] as const)('%s relation remains binding in working-days mode', (_name, kind, A, B) => {
219+
const r1 = rel('A', 'B', kind)
220+
const res = computeCriticalPath([A, B], [r1], cfgMonFri)
221+
expect(res.critical.has('A' as Ref<Issue>)).toBe(true)
222+
expect(res.critical.has('B' as Ref<Issue>)).toBe(true)
223+
expect(res.criticalRelations.has(r1._id)).toBe(true)
224+
})
225+
226+
it('a holiday is skipped by a tight FS relation in both CPM passes', () => {
201227
const cfgWithHoliday = { weekdayMask: 0b0011111, holidays: [Date.UTC(2026, 4, 19)] }
202228
const A = issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 18)) // Mon only
203-
const B = issue('B', Date.UTC(2026, 4, 19), Date.UTC(2026, 4, 19)) // Tue (holiday)
229+
const B = issue('B', Date.UTC(2026, 4, 20), Date.UTC(2026, 4, 20)) // Wed
204230
const r1 = rel('A', 'B', 'finish-to-start', 0)
205231
const res = computeCriticalPath([A, B], [r1], cfgWithHoliday)
206-
expect(res.violatedRelations.has(r1._id)).toBe(true)
232+
expect(res.critical).toEqual(new Set(['A', 'B']))
233+
expect(res.criticalRelations.has(r1._id)).toBe(true)
234+
expect(res.violatedRelations.size).toBe(0)
235+
})
236+
237+
it('keeps a non-working project finish instead of normalising it', () => {
238+
const weekend = issue('weekend', Date.UTC(2026, 4, 23), Date.UTC(2026, 4, 24))
239+
const res = computeCriticalPath([weekend], [], cfgMonFri)
240+
// The sink LF is its stored Sunday finish; its one-working-day span then
241+
// yields Sunday LS, so slack stays in calendar milliseconds.
242+
expect(res.slack.get('weekend' as Ref<Issue>)).toBe(86_400_000)
243+
expect(res.critical.has('weekend' as Ref<Issue>)).toBe(false)
244+
})
245+
246+
it('uses startForSpan when FF gives a predecessor a non-working LF', () => {
247+
const A = issue('A', Date.UTC(2026, 4, 22), Date.UTC(2026, 4, 25)) // Fri..Mon: 2 wd
248+
const B = issue('B', Date.UTC(2026, 4, 23), Date.UTC(2026, 4, 24)) // Sat..Sun
249+
const res = computeCriticalPath([A, B], [rel('A', 'B', 'finish-to-finish')], cfgMonFri)
250+
// FF's reverse anchor keeps B's Sunday LF. startForSpan(Sun, 2) = Fri.
251+
expect(res.slack.get('A' as Ref<Issue>)).toBe(0)
252+
})
253+
254+
it('latest start of a predecessor across a weekend is measured in working days', () => {
255+
const A = issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 20)) // Mon..Wed, 3 wd
256+
const B = issue('B', Date.UTC(2026, 4, 27), Date.UTC(2026, 4, 27)) // Wed next week
257+
const res = computeCriticalPath([A, B], [rel('A', 'B')], cfgMonFri)
258+
// LF(A) = fsReverseAnchor(Wed 27) = Tue 26; LS(A) = startForSpan(Tue 26, 3) = Fri 22.
259+
expect(res.slack.get('A' as Ref<Issue>)).toBe(4 * 86_400_000)
260+
expect(res.slack.get('B' as Ref<Issue>)).toBe(0)
261+
expect(res.critical).toEqual(new Set(['B']))
262+
})
263+
264+
it('legacy mode: latest start of the same predecessor uses the calendar-day duration', () => {
265+
const A = issue('A', Date.UTC(2026, 4, 18), Date.UTC(2026, 4, 20))
266+
const B = issue('B', Date.UTC(2026, 4, 27), Date.UTC(2026, 4, 27))
267+
const res = computeCriticalPath([A, B], [rel('A', 'B')])
268+
// LF(A) = Wed 27 - 1 d = Tue 26; LS(A) = Tue 26 - 2 d = Sun 24.
269+
expect(res.slack.get('A' as Ref<Issue>)).toBe(6 * 86_400_000)
207270
})
208271

209272
it('legacy mode unchanged: simple chain still matches existing semantics', () => {

‎plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler-cascade.test.ts‎

Lines changed: 208 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ import {
2323
ffAnchor,
2424
ffReverseAnchor,
2525
sfAnchor,
26-
sfReverseAnchor
26+
sfReverseAnchor,
27+
MAX_WORKING_SPAN_DAYS
2728
} from '@hcengineering/gantt'
2829
import type { Issue, IssueRelation } from '@hcengineering/tracker'
2930
import type { Ref } from '@hcengineering/core'
3031
import type { DragState, DragTarget, PrimaryEdit } from '../types'
3132

33+
const DAY_MS = 86_400_000
34+
3235
function issue (id: string, start?: number, due?: number): Issue {
3336
return {
3437
_id: id as Ref<Issue>,
@@ -512,6 +515,8 @@ describe('simulateCascade — working-days mode', () => {
512515
if (res.kind !== 'cascade') return
513516
// fsAnchor(Fri May 22, 1 wd, Mo-Fr) = Mon May 25.
514517
expect(res.shifts[0].newStart).toBe(Date.UTC(2026, 4, 25))
518+
// B keeps its five working days: Mon May 25 .. Fri May 29.
519+
expect(res.shifts[0].newDue).toBe(Date.UTC(2026, 4, 29))
515520
})
516521

517522
it('FS lag=2 with Mo-Fr cfg: 2 working days after Friday = Wednesday next week', () => {
@@ -524,6 +529,8 @@ describe('simulateCascade — working-days mode', () => {
524529
if (res.kind !== 'cascade') return
525530
// fsAnchor(Fri May 22, (1 + 2) wd, Mo-Fr) = Wed May 27.
526531
expect(res.shifts[0].newStart).toBe(Date.UTC(2026, 4, 27))
532+
// B keeps its five working days: Wed May 27 .. Tue Jun 2.
533+
expect(res.shifts[0].newDue).toBe(Date.UTC(2026, 5, 2))
527534
})
528535

529536
it('legacy (cfg=undefined): FS push uses calendar days with the +1-day rule', () => {
@@ -564,6 +571,8 @@ describe('simulateCascade — working-days mode', () => {
564571
// fsReverseAnchor(Mon May 25, 0, cfg) = previous Fri May 22 → A.newDue = May 22.
565572
expect(res.shifts[0].issue._id).toBe('A')
566573
expect(res.shifts[0].newDue).toBe(Date.UTC(2026, 4, 22))
574+
// A keeps its six working days (Mon Jun 1 .. Mon Jun 8): Fri May 15 .. Fri May 22.
575+
expect(res.shifts[0].newStart).toBe(Date.UTC(2026, 4, 15))
567576
})
568577

569578
it('FS gap floor uses working days: drag A Fri→Mon (1 wd) pushes B by 1 working day, not 3 calendar days', () => {
@@ -656,23 +665,27 @@ describe('simulateCascade — working-days mode', () => {
656665
})
657666

658667
it('Test 6: multi-hop FS chain — floor dominates the second hop and propagates through the cascade', () => {
659-
// A: 1-day Thu 14. B: Fri 15 .. Mon 18 (weekend spanner, zero slack: fsAnchor(Thu 14) = Fri 15).
660-
// C: 1-day Wed 20 (1 wd slack: fsAnchor(Mon 18) = Tue 19). Primary: A → Sat 16 (pinned non-working).
668+
// A: 1-day Thu 14. B: Fri 15 .. Mon 18 (weekend spanner, two working days, zero slack:
669+
// fsAnchor(Thu 14) = Fri 15). C: 1-day Wed 20 (1 wd slack: fsAnchor(Mon 18) = Tue 19).
670+
// Primary: A → Mon 18.
661671
const A = issue('A', Date.UTC(2026, 4, 14), Date.UTC(2026, 4, 14))
662672
const B = issue('B', Date.UTC(2026, 4, 15), Date.UTC(2026, 4, 18))
663673
const C = issue('C', Date.UTC(2026, 4, 20), Date.UTC(2026, 4, 20))
664674
const relations = [rel('A', 'B', 'finish-to-start', 0), rel('B', 'C', 'finish-to-start', 0)]
665-
const primary: PrimaryEdit[] = [{ issue: A, newStart: Date.UTC(2026, 4, 16), newDue: Date.UTC(2026, 4, 16) }]
675+
const primary: PrimaryEdit[] = [{ issue: A, newStart: Date.UTC(2026, 4, 18), newDue: Date.UTC(2026, 4, 18) }]
666676
const res = simulateCascade(primary, [A, B, C], relations, () => true, { workingDays: cfgMonFri })
667677
expect(res.kind).toBe('cascade')
668678
if (res.kind !== 'cascade') return
669679
const byId = new Map(res.shifts.map((s) => [s.issue._id, s]))
670-
// Hop 1 (A→B): snap = floor = Mon 18 (anchor case) → B keeps its 3-day span → Mon 18 .. Thu 21.
671-
expect(byId.get('B' as Ref<Issue>)?.newStart).toBe(Date.UTC(2026, 4, 18))
672-
expect(byId.get('B' as Ref<Issue>)?.newDue).toBe(Date.UTC(2026, 4, 21))
673-
// Hop 2 (B→C): floor addWorkingDays(Wed 20, 3) = Mon 25 > snap fsAnchor(Thu 21) = Fri 22 → floor wins.
674-
expect(byId.get('C' as Ref<Issue>)?.newStart).toBe(Date.UTC(2026, 4, 25))
675-
expect(byId.get('C' as Ref<Issue>)?.newDue).toBe(Date.UTC(2026, 4, 25))
680+
// Hop 1 (A→B): floor addWorkingDays(Fri 15, workingDayDelta(Thu 14, Mon 18) = 2) = Tue 19
681+
// = snap fsAnchor(Mon 18) → B keeps its two working days → Tue 19 .. Wed 20.
682+
expect(byId.get('B' as Ref<Issue>)?.newStart).toBe(Date.UTC(2026, 4, 19))
683+
expect(byId.get('B' as Ref<Issue>)?.newDue).toBe(Date.UTC(2026, 4, 20))
684+
// Hop 2 (B→C): floor addWorkingDays(Wed 20, workingDayDelta(Mon 18, Wed 20) = 2) = Fri 22
685+
// > snap fsAnchor(Wed 20) = Thu 21 → floor wins.
686+
expect(byId.get('C' as Ref<Issue>)?.newStart).toBe(Date.UTC(2026, 4, 22))
687+
expect(byId.get('C' as Ref<Issue>)?.newDue).toBe(Date.UTC(2026, 4, 22))
688+
expect(res.shifts.some((s) => s.reason === 'pull-predecessor')).toBe(false)
676689
expect(isWorkingDay(byId.get('C' as Ref<Issue>)?.newStart as number, cfgMonFri)).toBe(true)
677690
})
678691

@@ -754,7 +767,7 @@ describe('simulateCascade — working-days mode: reverse pass must not undo a sa
754767
const cfg = { weekdayMask: 0b0011111, holidays: [Date.UTC(2026, 9, 22)] }
755768
const oct = (d: number): number => Date.UTC(2026, 9, d)
756769

757-
it('chain push A→B→C where B ends on a Saturday: B keeps Fri 16–Sat 17, no pull-predecessor', () => {
770+
it('chain push A→B→C: B keeps its two working days (Fri 16–Mon 19), C follows on Tue 20–Wed 21', () => {
758771
// A Mon 5–Fri 9, B Mon 12–Tue 13, C Wed 14–Thu 15 (FS chain).
759772
const A = issue('A', oct(5), oct(9))
760773
const B = issue('B', oct(12), oct(13))
@@ -766,10 +779,11 @@ describe('simulateCascade — working-days mode: reverse pass must not undo a sa
766779
expect(res.kind).toBe('cascade')
767780
if (res.kind !== 'cascade') return
768781
const byId = new Map(res.shifts.map((s) => [s.issue._id as string, s]))
769-
// fsAnchor(Thu 15) = Fri 16 → B Fri 16–Sat 17.
770-
expect(byId.get('B')).toMatchObject({ newStart: oct(16), newDue: oct(17), reason: 'push-successor' })
771-
// fsAnchor(Sat 17) = Mon 19 → C Mon 19–Tue 20.
772-
expect(byId.get('C')).toMatchObject({ newStart: oct(19), newDue: oct(20), reason: 'push-successor' })
782+
// fsAnchor(Thu 15) = Fri 16 → B keeps two working days: Fri 16–Mon 19.
783+
expect(byId.get('B')).toMatchObject({ newStart: oct(16), newDue: oct(19), reason: 'push-successor' })
784+
// fsAnchor(Mon 19) = Tue 20 = floor addWorkingDays(Wed 14, workingDayDelta(Tue 13, Mon 19) = 4)
785+
// → C Tue 20–Wed 21; fsAnchor(B.due Mon 19) = Tue 20 = C.start, so no pull.
786+
expect(byId.get('C')).toMatchObject({ newStart: oct(20), newDue: oct(21), reason: 'push-successor' })
773787
expect(res.shifts.some((s) => s.reason === 'pull-predecessor')).toBe(false)
774788
})
775789

@@ -783,16 +797,193 @@ describe('simulateCascade — working-days mode: reverse pass must not undo a sa
783797
})
784798

785799
it("successor dragged onto the predecessor's Friday (real violation) → predecessor pulled to Wed 14–Thu 15", () => {
786-
const B = issue('B', oct(16), oct(17))
800+
// B Thu 15–Fri 16 (two working days).
801+
const B = issue('B', oct(15), oct(16))
787802
const C = issue('C', oct(21), oct(23))
788803
const primary: PrimaryEdit[] = [{ issue: C, newStart: oct(16), newDue: oct(19) }]
789804
const res = simulateCascade(primary, [B, C], [rel('B', 'C')], () => true, { workingDays: cfg })
790805
expect(res.kind).toBe('cascade')
791806
if (res.kind !== 'cascade') return
792-
// fsReverseAnchor(Fri 16) = Thu 15 → B shifted back by 2 calendar days.
807+
// fsReverseAnchor(Fri 16) = Thu 15 → B keeps two working days: Wed 14–Thu 15.
793808
expect(res.shifts).toHaveLength(1)
794809
expect(res.shifts[0]).toMatchObject({ newStart: oct(14), newDue: oct(15), reason: 'pull-predecessor' })
795810
})
811+
812+
it('a pulled predecessor stored on Fri 16–Sat 17 becomes a one-working-day bar (Thu 15)', () => {
813+
const B = issue('B', oct(16), oct(17))
814+
const C = issue('C', oct(21), oct(23))
815+
const primary: PrimaryEdit[] = [{ issue: C, newStart: oct(16), newDue: oct(19) }]
816+
const res = simulateCascade(primary, [B, C], [rel('B', 'C')], () => true, { workingDays: cfg })
817+
expect(res.kind).toBe('cascade')
818+
if (res.kind !== 'cascade') return
819+
// workingDaySpan(Fri 16, Sat 17) = 1 → B Thu 15–Thu 15.
820+
expect(res.shifts).toHaveLength(1)
821+
expect(res.shifts[0]).toMatchObject({ newStart: oct(15), newDue: oct(15), reason: 'pull-predecessor' })
822+
})
823+
})
824+
825+
describe('simulateCascade — working-days mode: shifted issues keep their working-day span', () => {
826+
const cfgMonFri = { weekdayMask: 0b0011111, holidays: [] }
827+
const may = (d: number): number => Date.UTC(2026, 4, d)
828+
const jun = (d: number): number => Date.UTC(2026, 5, d)
829+
830+
function run (
831+
issues: Issue[],
832+
relations: IssueRelation[],
833+
primary: PrimaryEdit[],
834+
workingDays: { weekdayMask: number, holidays: number[] } | null = cfgMonFri
835+
): Map<string, { newStart: number, newDue: number, reason: string }> {
836+
const res = simulateCascade(
837+
primary,
838+
issues,
839+
relations,
840+
() => true,
841+
workingDays === null ? undefined : { workingDays }
842+
)
843+
expect(res.kind).toBe('cascade')
844+
if (res.kind !== 'cascade') return new Map()
845+
return new Map(res.shifts.map((s) => [s.issue._id as string, s]))
846+
}
847+
848+
it('push keeps a five-working-day successor at five working days across the weekend', () => {
849+
const A = issue('A', may(18), may(22))
850+
const B = issue('B', may(25), may(29))
851+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(20), newDue: may(26) }])
852+
expect(byId.get('B')).toMatchObject({ newStart: may(27), newDue: jun(2), reason: 'push-successor' })
853+
expect(isWorkingDay(byId.get('B')?.newDue as number, cfgMonFri)).toBe(true)
854+
})
855+
856+
it('FF push across a weekend anchors the due date and keeps the span', () => {
857+
const A = issue('A', may(18), may(20))
858+
const B = issue('B', may(18), may(20))
859+
const byId = run([A, B], [rel('A', 'B', 'finish-to-finish')], [{ issue: A, newStart: may(18), newDue: may(26) }])
860+
expect(byId.get('B')).toMatchObject({ newStart: may(22), newDue: may(26), reason: 'push-successor' })
861+
})
862+
863+
it('FS pull across a weekend keeps the predecessor span', () => {
864+
// A Thu 14 – Mon 18 = three working days.
865+
const A = issue('A', may(14), may(18))
866+
const B = issue('B', may(19), may(20))
867+
const res = simulateCascade(
868+
[{ issue: B, newStart: may(11), newDue: may(12) }],
869+
[A, B],
870+
[rel('A', 'B')],
871+
() => true,
872+
{ workingDays: cfgMonFri }
873+
)
874+
expect(res.kind).toBe('cascade')
875+
if (res.kind !== 'cascade') return
876+
expect(res.shifts).toHaveLength(1)
877+
expect(res.shifts[0]).toMatchObject({ newStart: may(6), newDue: may(8), reason: 'pull-predecessor' })
878+
})
879+
880+
it('a pushed bar steps over a holiday', () => {
881+
const cfgHol = { weekdayMask: 0b0011111, holidays: [may(21)] }
882+
const A = issue('A', may(15), may(15))
883+
const B = issue('B', may(18), may(19))
884+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(19), newDue: may(19) }], cfgHol)
885+
expect(byId.get('B')).toMatchObject({ newStart: may(20), newDue: may(22), reason: 'push-successor' })
886+
})
887+
888+
it('a bar stored on non-working days becomes a one-working-day bar when pushed', () => {
889+
const A = issue('A', may(15), may(15))
890+
const B = issue('B', may(16), may(17))
891+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(18), newDue: may(18) }])
892+
expect(byId.get('B')).toMatchObject({ newStart: may(19), newDue: may(19), reason: 'push-successor' })
893+
})
894+
895+
it('an inverted bar becomes at least a one-day bar when pushed', () => {
896+
const A = issue('A', may(18), may(18))
897+
const B = issue('B', may(19), may(18))
898+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(19), newDue: may(19) }])
899+
expect(byId.get('B')).toMatchObject({ newStart: may(20), newDue: may(20), reason: 'push-successor' })
900+
})
901+
902+
it('a bar beyond the working-span cap keeps its calendar-day length when pushed', () => {
903+
const A = issue('A', may(18), may(18))
904+
const bStart = may(19)
905+
const bDue = bStart + MAX_WORKING_SPAN_DAYS * DAY_MS
906+
const B = issue('B', bStart, bDue)
907+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(21), newDue: may(21) }])
908+
expect(byId.get('B')).toMatchObject({
909+
newStart: may(22),
910+
newDue: bDue + 3 * DAY_MS,
911+
reason: 'push-successor'
912+
})
913+
expect((byId.get('B')?.newDue as number) - (byId.get('B')?.newStart as number)).toBe(bDue - bStart)
914+
})
915+
916+
it('SS push across a weekend keeps the successor span', () => {
917+
const A = issue('A', may(18), may(20))
918+
const B = issue('B', may(18), may(19))
919+
const byId = run([A, B], [rel('A', 'B', 'start-to-start')], [{ issue: A, newStart: may(22), newDue: may(26) }])
920+
expect(byId.get('B')).toMatchObject({ newStart: may(22), newDue: may(25), reason: 'push-successor' })
921+
})
922+
923+
it('SF push across a weekend anchors the due date and keeps the span', () => {
924+
const A = issue('A', may(18), may(20))
925+
const B = issue('B', may(11), may(12))
926+
const byId = run([A, B], [rel('A', 'B', 'start-to-finish')], [{ issue: A, newStart: may(25), newDue: may(27) }])
927+
expect(byId.get('B')).toMatchObject({ newStart: may(22), newDue: may(25), reason: 'push-successor' })
928+
})
929+
930+
function pullOnly (
931+
A: Issue,
932+
B: Issue,
933+
kind: 'start-to-start' | 'finish-to-finish' | 'start-to-finish',
934+
primary: PrimaryEdit,
935+
workingDays: { weekdayMask: number, holidays: number[] } | null = cfgMonFri
936+
): { newStart: number, newDue: number, reason: string } | undefined {
937+
const res = simulateCascade(
938+
[primary],
939+
[A, B],
940+
[rel('A', 'B', kind)],
941+
() => true,
942+
workingDays === null ? undefined : { workingDays }
943+
)
944+
expect(res.kind).toBe('cascade')
945+
if (res.kind !== 'cascade') return undefined
946+
expect(res.shifts).toHaveLength(1)
947+
return res.shifts[0]
948+
}
949+
950+
it('SS pull across a weekend keeps the predecessor span', () => {
951+
// A Thu 21 – Mon 25 = three working days.
952+
const A = issue('A', may(21), may(25))
953+
const B = issue('B', may(21), may(22))
954+
const s = pullOnly(A, B, 'start-to-start', { issue: B, newStart: may(18), newDue: may(19) })
955+
expect(s).toMatchObject({ newStart: may(18), newDue: may(20), reason: 'pull-predecessor' })
956+
})
957+
958+
it('SF pull across a weekend keeps the predecessor span', () => {
959+
// A Fri 22 – Tue 26 = three working days.
960+
const A = issue('A', may(22), may(26))
961+
const B = issue('B', may(20), may(22))
962+
const s = pullOnly(A, B, 'start-to-finish', { issue: B, newStart: may(18), newDue: may(20) })
963+
expect(s).toMatchObject({ newStart: may(20), newDue: may(22), reason: 'pull-predecessor' })
964+
})
965+
966+
it('FF pull across a weekend keeps the predecessor span', () => {
967+
// A Thu 21 – Mon 25 = three working days.
968+
const A = issue('A', may(21), may(25))
969+
const B = issue('B', may(25), may(27))
970+
const s = pullOnly(A, B, 'finish-to-finish', { issue: B, newStart: may(18), newDue: may(20) })
971+
expect(s).toMatchObject({ newStart: may(18), newDue: may(20), reason: 'pull-predecessor' })
972+
})
973+
974+
it('legacy mode keeps the calendar-day length on push', () => {
975+
const A = issue('A', may(18), may(22))
976+
const B = issue('B', may(25), may(29))
977+
const byId = run([A, B], [rel('A', 'B')], [{ issue: A, newStart: may(20), newDue: may(26) }], null)
978+
expect(byId.get('B')).toMatchObject({ newStart: may(27), newDue: may(31), reason: 'push-successor' })
979+
})
980+
981+
it('legacy mode keeps the calendar-day length on pull', () => {
982+
const A = issue('A', may(21), may(25))
983+
const B = issue('B', may(21), may(22))
984+
const s = pullOnly(A, B, 'start-to-start', { issue: B, newStart: may(18), newDue: may(19) }, null)
985+
expect(s).toMatchObject({ newStart: may(18), newDue: may(22), reason: 'pull-predecessor' })
986+
})
796987
})
797988

798989
describe('simulateCascade — legacy mode: reverse-pass guard is a no-op', () => {

0 commit comments

Comments
 (0)