diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs index 3f0f8322..efea0ce2 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs @@ -974,24 +974,238 @@ public void AggregatePauseMinutes_LegacyMode_15MinPauseReturns15() } /// - /// When UseOneMinuteIntervals is on, the legacy Pause*Id field must be - /// ignored: a row with Pause1Id = 4 (legacy 15 min) but no DateTime stamps - /// is treated as zero pause. + /// When UseOneMinuteIntervals is on but no stamp pairs are populated, + /// the helper falls back to the legacy 5-minute-tick formula on the 5 + /// main slots. This covers older flag-on rows written before stamp-pair + /// pauses existed. Pause1Id = 4 → (4 * 5) - 5 = 15 minutes via fallback. /// [Test] - public void AggregatePauseMinutes_OneMinuteInterval_NullStampsContributeZero() + public void AggregatePauseMinutes_OneMinuteInterval_NoStampsFallsBackToLegacyTicks() { // Arrange var pr = new PlanRegistration { - Pause1Id = 4, // legacy 15-min pause; flag-on path must ignore this + Pause1Id = 4, // legacy 15-min pause; flag-on path falls back when no stamps // Pause1StartedAt / Pause1StoppedAt left null }; // Act var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + // Assert — legacy fallback now applies since no stamp pairs are populated. + Assert.That(result, Is.EqualTo(15)); + } + + /// + /// Customer 855 root-cause regression: with UseOneMinuteIntervals = true, + /// a 3-minute pause stamped in a SUB-SLOT (Pause10StartedAt / Pause10StoppedAt) + /// rather than the main Pause1 slot must aggregate to 3, not 0. Before the + /// 31-pair sub-slot scan, the backend overview rendered "Samlet pause: 00:00" + /// while the edit dialog (which already scanned sub-slots) showed Pause: 00:03. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause10StartedAt = someDate.AddHours(12), + Pause10StoppedAt = someDate.AddHours(12).AddMinutes(3), + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(3)); + } + + /// + /// Shift-1 multi-pause case: pauses in BOTH the main slot (Pause1) and a + /// sub-slot (Pause11) must sum (4 + 6 = 10). Proves the loop visits all + /// populated pairs, not just the first. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_SumsPause1AndPause11() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddHours(10), + Pause1StoppedAt = someDate.AddHours(10).AddMinutes(4), + Pause11StartedAt = someDate.AddHours(11), + Pause11StoppedAt = someDate.AddHours(11).AddMinutes(6), + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(10)); + } + + /// + /// Shift-2 sub-slot case: pauses in Pause2 main slot AND Pause20 sub-slot + /// must sum (5 + 8 = 13). Mirrors the shift-1 multi-pause case for shift 2. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_SumsPause2AndPause20() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause2StartedAt = someDate.AddHours(13), + Pause2StoppedAt = someDate.AddHours(13).AddMinutes(5), + Pause20StartedAt = someDate.AddHours(15), + Pause20StoppedAt = someDate.AddHours(15).AddMinutes(8), + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(13)); + } + + /// + /// Shift 3 single-slot case: a pause stamped in Pause3StartedAt/Pause3StoppedAt + /// must aggregate to its true minute delta when the flag is on. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_Pause3SingleSlot() + { + // Arrange + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause3StartedAt = someDate.AddHours(17), + Pause3StoppedAt = someDate.AddHours(17).AddMinutes(9), + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(9)); + } + + /// + /// Flag-on legacy fallback: a row with no stamps but Pause1Id = 2 (the legacy + /// 5-min companion) must return 5 minutes via the (Pause1Id * 5) - 5 formula. + /// Covers the older flag-on rows that pre-date the stamp-pair multi-pause flow. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_NoStamps_Pause1Id2_ReturnsFiveMinutes() + { + // Arrange + var pr = new PlanRegistration + { + Pause1Id = 2, // legacy 5-min companion: (2 * 5) - 5 = 5 + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(5)); + } + + /// + /// Flag-on, every stamp and every Pause*Id is null/zero — the helper must + /// return 0. Guards against accidental defaults leaking nonzero output. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_EverythingEmptyReturnsZero() + { + // Arrange + var pr = new PlanRegistration(); + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert + Assert.That(result, Is.EqualTo(0)); + } + + /// + /// Flag-on, stamps are populated but the duration is zero + /// (StartedAt == StoppedAt). Even though the duration sum is 0, the + /// helper must NOT fall back to legacy ticks — the worker did stamp a + /// pause and the intended display is 0 minutes. Pause1Id = 4 (legacy + /// 15 min) must be ignored. + /// + /// Guards against the fallback being gated on totalSeconds == 0 + /// instead of "no stamp observed" (Copilot review on PR #1575). + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_ZeroDurationStampDoesNotFallBackToLegacy() + { + // Arrange — stamps describe a 0-min pause (start == stop). + var someDate = new DateTime(2026, 5, 7, 12, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate, + Pause1StoppedAt = someDate, + Pause1Id = 4, // legacy 15 min — must be ignored because stamps were observed. + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + + // Assert — stamps were observed, so the (0-min) stamp result wins + // over the legacy fallback that would otherwise return 15. + Assert.That(result, Is.EqualTo(0)); + } + + /// + /// Flag-on, stamps are populated but the duration is NEGATIVE + /// (StoppedAt < StartedAt — clock skew or data corruption). Same + /// contract as the zero-duration case: stamps were observed, so the + /// legacy fallback must NOT trigger. + /// + [Test] + public void AggregatePauseMinutes_OneMinuteInterval_NegativeDurationStampDoesNotFallBackToLegacy() + { + // Arrange — stamps describe an invalid pause (stop before start). + var someDate = new DateTime(2026, 5, 7, 12, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddMinutes(5), + Pause1StoppedAt = someDate, + Pause1Id = 4, // legacy 15 min — must be ignored. + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true); + // Assert Assert.That(result, Is.EqualTo(0)); } + + /// + /// Flag-off parity: stamps populated and legacy Pause1Id both present. + /// The flag-off branch must still return only the legacy tick result + /// (15 here) — proves the new flag-on logic did not touch the flag-off path. + /// + [Test] + public void AggregatePauseMinutes_LegacyMode_StampsPopulated_StillReturnsLegacyTicks() + { + // Arrange — stamps describe a 3-min pause, Pause1Id = 4 is a legacy 15-min companion. + var someDate = new DateTime(2026, 5, 7, 0, 0, 0); + var pr = new PlanRegistration + { + Pause1StartedAt = someDate.AddHours(12), + Pause1StoppedAt = someDate.AddHours(12).AddMinutes(3), + Pause1Id = 4, // legacy 15 min + }; + + // Act + var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false); + + // Assert — flag-off branch ignores stamps and uses legacy ticks only. + Assert.That(result, Is.EqualTo(15)); + } } \ No newline at end of file diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs index b9a43b3f..aceff5af 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -9,6 +10,7 @@ using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site; using NSubstitute; using NUnit.Framework; using TimePlanning.Pn.Infrastructure.Helpers; @@ -363,4 +365,164 @@ public async Task Update_NullModel_ReturnsFailureWithoutException() "Localization key must match the existing catch-block fallback so " + "the front-end's error surfacing is unchanged."); } + + /// + /// Service-level regression for PR #1575 (commits 6ebc5fe6 + 43da9f2e). When + /// an AssignedSite has UseOneMinuteIntervals = true and a PlanRegistration's + /// only pause sits in a SUB-SLOT pair (e.g. Pause10StartedAt/Pause10StoppedAt), + /// the per-day model the plannings overview consumes must surface PauseMinutes + /// from that stamp. Before the fix the backend only iterated Pause1..Pause5 + /// and reported Samlet pause 00:00 while the edit dialog (which already + /// scanned sub-slots) showed 00:03. + /// + /// Exercises the exact call chain Index() takes + /// Index() → PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod() + /// → PlanRegistrationHelper.AggregatePauseMinutes() + /// against a real TimePlanningPnDbContext, so a refactor that drops the + /// AggregatePauseMinutes call from UpdatePlanRegistrationsInPeriod fails + /// here even though the helper-level unit tests in + /// PlanRegistrationHelperTests.cs still pass. + /// + /// (The full Index() path additionally requires real BaseDbContext.Users + + /// sdkDbContext.Sites seeding for the admin-gate + per-site lookup; that + /// fixture is the same gap currently [Ignore]d across the suite — see + /// SettingsServiceExtendedTests.cs for the rationale. The call-site test + /// here is the smallest unit that still observes the bug end-to-end.) + /// + [Test] + public async Task Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly() + { + // Arrange — flag ON site, planning row with ONLY a sub-slot pause + // (Pause10*). Pause1Id stays 0 so we prove the legacy fallback isn't + // the thing producing the 3. + var assignedSite = new AssignedSiteEntity + { + SiteId = 904, + UseOneMinuteIntervals = true, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 5, 14, 0, 0, 0, DateTimeKind.Utc); + var planning = new PlanRegistration + { + SdkSitId = 904, + Date = date, + // Sub-slot pause: 12:00 → 12:03, no Pause1*. + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(3), + Pause1Id = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + // Re-pull the registration the same shape Index() does (Id + Date only — + // UpdatePlanRegistrationsInPeriod re-fetches the full row internally). + var planningsInPeriod = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .Where(x => x.SdkSitId == 904) + .Select(x => new PlanRegistration { Id = x.Id, Date = x.Date }) + .ToListAsync(); + + var siteModel = new TimePlanningPlanningModel + { + SiteId = 904, + SiteName = "Test site 904", + UseOneMinuteIntervals = true, + PlanningPrDayModels = new List() + }; + + var sdkSite = new SdkSite { Name = "Test site 904", MicrotingUid = 904 }; + + // Act — call the exact helper Index() invokes to build the per-day models. + var result = await PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod( + planningsInPeriod, + siteModel, + TimePlanningPnDbContext, + assignedSite, + Substitute.For>(), + sdkSite, + date.AddDays(-1), + date.AddDays(1), + _options); + + // Assert — per-day model for 2026-05-14 surfaces PauseMinutes = 3 from + // the sub-slot stamp pair, NOT 0 (the pre-fix observable bug). + var prDay = result.PlanningPrDayModels.Single(x => x.Date.Date == date.Date); + Assert.That(prDay.PauseMinutes, Is.EqualTo(3.0), + "Sub-slot pause stamps (Pause10*) must aggregate when UseOneMinuteIntervals = true."); + } + + /// + /// Negative companion to Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly: + /// when UseOneMinuteIntervals = false the same row's PauseMinutes must be 0, + /// because the legacy 5-minute-grid path doesn't scan sub-slot DateTime + /// stamps — it falls back to (Pause1Id - 1) * 5, and Pause1Id = 0 yields 0. + /// + /// This pair (flag-on / flag-off with identical seed) proves the flag itself + /// is the toggle that flips the aggregation path through the service. + /// + [Test] + public async Task Index_LegacyFiveMinuteFlag_WithSubSlotPauseStamps_PauseMinutesIsZero() + { + // Arrange — flag OFF, same sub-slot pause seed as the positive test. + var assignedSite = new AssignedSiteEntity + { + SiteId = 905, + UseOneMinuteIntervals = false, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var date = new DateTime(2026, 5, 14, 0, 0, 0, DateTimeKind.Utc); + var planning = new PlanRegistration + { + SdkSitId = 905, + Date = date, + Pause10StartedAt = date.AddHours(12), + Pause10StoppedAt = date.AddHours(12).AddMinutes(3), + Pause1Id = 0, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + var planningsInPeriod = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .Where(x => x.SdkSitId == 905) + .Select(x => new PlanRegistration { Id = x.Id, Date = x.Date }) + .ToListAsync(); + + var siteModel = new TimePlanningPlanningModel + { + SiteId = 905, + SiteName = "Test site 905", + UseOneMinuteIntervals = false, + PlanningPrDayModels = new List() + }; + + var sdkSite = new SdkSite { Name = "Test site 905", MicrotingUid = 905 }; + + // Act + var result = await PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod( + planningsInPeriod, + siteModel, + TimePlanningPnDbContext, + assignedSite, + Substitute.For>(), + sdkSite, + date.AddDays(-1), + date.AddDays(1), + _options); + + // Assert — flag-off path can't see sub-slot stamps. With Pause1Id = 0 + // the legacy formula (Pause1Id - 1) * 5 yields 0 (the function returns + // 0 when Pause1Id is zero rather than going negative). + var prDay = result.PlanningPrDayModels.Single(x => x.Date.Date == date.Date); + Assert.That(prDay.PauseMinutes, Is.EqualTo(0.0), + "Legacy flag-off path must NOT pick up sub-slot pause stamps."); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs index 64d5ebd3..726a27c1 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs @@ -451,30 +451,68 @@ long ShiftSeconds(DateTime? startAt, DateTime? stopAt, int startId, int stopId, /// Aggregates pause minutes for a PlanRegistration. /// /// When useOneMinuteIntervals is true, sums the (Pause*StoppedAt - Pause*StartedAt) - /// DateTime deltas in seconds across all 5 pause slots that have BOTH stamps populated, - /// then rounds to whole minutes. This preserves second/minute precision for pauses - /// recorded by workers on UseOneMinuteIntervals-enabled sites (e.g. a 3-min pause - /// reads as 3, not 0). + /// DateTime deltas in seconds across every populated pause stamp pair — the 5 main + /// slots AND all sub-slots used by the multi-pause workflow (Pause10..Pause19, + /// Pause100..Pause102 for shift 1; Pause20..Pause29, Pause200..Pause202 for shift 2; + /// Pause3/4/5 single slots for shifts 3/4/5). This mirrors the frontend admin edit + /// dialog's computeExactPauseMinutes (workday-entity-dialog.component.ts) so the + /// overview's "Samlet pause" and the per-row edit dialog agree byte-for-byte. + /// Total seconds are rounded down to whole minutes. /// - /// When useOneMinuteIntervals is false, falls back to the legacy 5-minute-tick - /// formula: for each Pause*Id > 0, contribute (Pause*Id * 5) - 5 minutes. (Pause*Id - /// stores break in 5-minute ticks plus a +1 sentinel: Pause1Id = 1 means 0 min, - /// Pause1Id = 4 means 15 min, etc.) + /// If a flag-on row has no stamp pairs populated, falls back to the legacy + /// 5-minute-tick formula on the 5 main slots only (sub-slots have no *Id field): + /// for each Pause{1..5}Id > 0, contribute (Pause{N}Id * 5) - 5 minutes. This + /// covers older flag-on rows written before stamp-pair pauses existed. + /// + /// When useOneMinuteIntervals is false, always uses the legacy 5-minute-tick + /// formula on the 5 main slots. (Pause*Id stores break in 5-minute ticks plus a + /// +1 sentinel: Pause1Id = 1 means 0 min, Pause1Id = 4 means 15 min, etc.) /// public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIntervals) { if (useOneMinuteIntervals) { long totalSeconds = 0; - totalSeconds += PauseSpanSeconds(pr.Pause1StartedAt, pr.Pause1StoppedAt); - totalSeconds += PauseSpanSeconds(pr.Pause2StartedAt, pr.Pause2StoppedAt); - totalSeconds += PauseSpanSeconds(pr.Pause3StartedAt, pr.Pause3StoppedAt); - totalSeconds += PauseSpanSeconds(pr.Pause4StartedAt, pr.Pause4StoppedAt); - totalSeconds += PauseSpanSeconds(pr.Pause5StartedAt, pr.Pause5StoppedAt); + var hasAnyStamp = false; + + // Walk every pause stamp pair (31 total — mirrors the frontend's + // computeExactPauseMinutes via the shared EnumeratePauseStampPairs + // source of truth that GetPauseIntervals also consumes). Track + // whether ANY stamp was observed independently of the duration sum, + // so a populated-but-zero-duration pause (start == stop) doesn't + // wrongly trigger the legacy-tick fallback. + foreach (var (startedAt, stoppedAt) in EnumeratePauseStampPairs(pr)) + { + if (startedAt.HasValue || stoppedAt.HasValue) + { + hasAnyStamp = true; + } + if (startedAt.HasValue && stoppedAt.HasValue && startedAt.Value < stoppedAt.Value) + { + totalSeconds += (long)(stoppedAt.Value - startedAt.Value).TotalSeconds; + } + } + + if (!hasAnyStamp) + { + // Older flag-on rows without any stamp data may still carry + // legacy 5-minute-tick IDs in the 5 main pause slots. + return LegacyTickMinutesAcrossMainSlots(pr); + } + return (int)(totalSeconds / 60); // round down to whole minutes } - // Legacy 5-minute-tick path + // Flag-off branch: legacy 5-minute-tick path. + return LegacyTickMinutesAcrossMainSlots(pr); + } + + /// + /// Sums the legacy 5-minute-tick pause IDs across the 5 main slots only. + /// Sub-slots have no *Id field, so they cannot contribute legacy ticks. + /// + private static int LegacyTickMinutesAcrossMainSlots(PlanRegistration pr) + { var totalMinutes = 0; if (pr.Pause1Id > 0) totalMinutes += (pr.Pause1Id * 5) - 5; if (pr.Pause2Id > 0) totalMinutes += (pr.Pause2Id * 5) - 5; @@ -2005,57 +2043,68 @@ public static async Task ReadBySiteAndDate( } } + /// + /// Single source of truth for every pause stamp pair on a PlanRegistration. + /// Enumerates Pause1-5, Pause10-19, Pause20-29, Pause100-102, Pause200-202 + /// — 31 pairs total. Order mirrors the frontend's + /// workday-entity-dialog.component.ts:getPauseTimestampPairs so the + /// backend↔frontend mapping is easy to audit. + /// + /// Returns raw nullable pairs so callers can distinguish "no stamp" + /// from "stamp with zero/negative duration"; both + /// and + /// consume this enumerator. + /// + private static IEnumerable<(DateTime? StartedAt, DateTime? StoppedAt)> EnumeratePauseStampPairs(PlanRegistration pr) + { + // Main pause intervals 1-5 + yield return (pr.Pause1StartedAt, pr.Pause1StoppedAt); + yield return (pr.Pause2StartedAt, pr.Pause2StoppedAt); + yield return (pr.Pause3StartedAt, pr.Pause3StoppedAt); + yield return (pr.Pause4StartedAt, pr.Pause4StoppedAt); + yield return (pr.Pause5StartedAt, pr.Pause5StoppedAt); + + // Extended pause intervals 10-29 + yield return (pr.Pause10StartedAt, pr.Pause10StoppedAt); + yield return (pr.Pause11StartedAt, pr.Pause11StoppedAt); + yield return (pr.Pause12StartedAt, pr.Pause12StoppedAt); + yield return (pr.Pause13StartedAt, pr.Pause13StoppedAt); + yield return (pr.Pause14StartedAt, pr.Pause14StoppedAt); + yield return (pr.Pause15StartedAt, pr.Pause15StoppedAt); + yield return (pr.Pause16StartedAt, pr.Pause16StoppedAt); + yield return (pr.Pause17StartedAt, pr.Pause17StoppedAt); + yield return (pr.Pause18StartedAt, pr.Pause18StoppedAt); + yield return (pr.Pause19StartedAt, pr.Pause19StoppedAt); + yield return (pr.Pause20StartedAt, pr.Pause20StoppedAt); + yield return (pr.Pause21StartedAt, pr.Pause21StoppedAt); + yield return (pr.Pause22StartedAt, pr.Pause22StoppedAt); + yield return (pr.Pause23StartedAt, pr.Pause23StoppedAt); + yield return (pr.Pause24StartedAt, pr.Pause24StoppedAt); + yield return (pr.Pause25StartedAt, pr.Pause25StoppedAt); + yield return (pr.Pause26StartedAt, pr.Pause26StoppedAt); + yield return (pr.Pause27StartedAt, pr.Pause27StoppedAt); + yield return (pr.Pause28StartedAt, pr.Pause28StoppedAt); + yield return (pr.Pause29StartedAt, pr.Pause29StoppedAt); + + // Additional pause intervals 100-102 + yield return (pr.Pause100StartedAt, pr.Pause100StoppedAt); + yield return (pr.Pause101StartedAt, pr.Pause101StoppedAt); + yield return (pr.Pause102StartedAt, pr.Pause102StoppedAt); + + // Additional pause intervals 200-202 + yield return (pr.Pause200StartedAt, pr.Pause200StoppedAt); + yield return (pr.Pause201StartedAt, pr.Pause201StoppedAt); + yield return (pr.Pause202StartedAt, pr.Pause202StoppedAt); + } + /// /// Extract pause intervals from PlanRegistration. - /// Includes Pause1-5, Pause10-29, Pause100-102, Pause200-202. - /// Returns intervals as (StartTime, EndTime) tuples. - /// Ignores incomplete or invalid intervals (null or negative duration). + /// Consumes and filters out incomplete + /// or invalid intervals (null endpoints or non-positive duration). /// private static IEnumerable<(DateTime Start, DateTime End)> GetPauseIntervals(PlanRegistration pr) { - var intervals = new (DateTime?, DateTime?)[] - { - // Main pause intervals 1-5 - (pr.Pause1StartedAt, pr.Pause1StoppedAt), - (pr.Pause2StartedAt, pr.Pause2StoppedAt), - (pr.Pause3StartedAt, pr.Pause3StoppedAt), - (pr.Pause4StartedAt, pr.Pause4StoppedAt), - (pr.Pause5StartedAt, pr.Pause5StoppedAt), - - // Extended pause intervals 10-29 - (pr.Pause10StartedAt, pr.Pause10StoppedAt), - (pr.Pause11StartedAt, pr.Pause11StoppedAt), - (pr.Pause12StartedAt, pr.Pause12StoppedAt), - (pr.Pause13StartedAt, pr.Pause13StoppedAt), - (pr.Pause14StartedAt, pr.Pause14StoppedAt), - (pr.Pause15StartedAt, pr.Pause15StoppedAt), - (pr.Pause16StartedAt, pr.Pause16StoppedAt), - (pr.Pause17StartedAt, pr.Pause17StoppedAt), - (pr.Pause18StartedAt, pr.Pause18StoppedAt), - (pr.Pause19StartedAt, pr.Pause19StoppedAt), - (pr.Pause20StartedAt, pr.Pause20StoppedAt), - (pr.Pause21StartedAt, pr.Pause21StoppedAt), - (pr.Pause22StartedAt, pr.Pause22StoppedAt), - (pr.Pause23StartedAt, pr.Pause23StoppedAt), - (pr.Pause24StartedAt, pr.Pause24StoppedAt), - (pr.Pause25StartedAt, pr.Pause25StoppedAt), - (pr.Pause26StartedAt, pr.Pause26StoppedAt), - (pr.Pause27StartedAt, pr.Pause27StoppedAt), - (pr.Pause28StartedAt, pr.Pause28StoppedAt), - (pr.Pause29StartedAt, pr.Pause29StoppedAt), - - // Additional pause intervals 100-102 - (pr.Pause100StartedAt, pr.Pause100StoppedAt), - (pr.Pause101StartedAt, pr.Pause101StoppedAt), - (pr.Pause102StartedAt, pr.Pause102StoppedAt), - - // Additional pause intervals 200-202 - (pr.Pause200StartedAt, pr.Pause200StoppedAt), - (pr.Pause201StartedAt, pr.Pause201StoppedAt), - (pr.Pause202StartedAt, pr.Pause202StoppedAt) - }; - - foreach (var (start, end) in intervals) + foreach (var (start, end) in EnumeratePauseStampPairs(pr)) { if (start.HasValue && end.HasValue && start.Value < end.Value) { diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts new file mode 100644 index 00000000..7dd5df6a --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +/** + * Plannings-table overview "Samlet pause" contract: when an AssignedSite has + * UseOneMinuteIntervals=true and a worker has a sub-slot pause (e.g. + * Pause10StartedAt/Pause10StoppedAt), the day cell must show the correct + * minute count instead of 00:00. Regression test for the bug fix introduced + * in commit 6ebc5fe6 + 43da9f2e (PR #1575) where the backend only iterated + * the 5 main pause slots and missed sub-slot stamps. + * + * Awaits DB fixture seeding for sub-slot pause stamps (the b1m CI shard's + * default seed flips UseOneMinuteIntervals on but does NOT write to Pause10* + * columns). Once that fixture lands, un-skip and the assertion below should + * pass. + * + * Until the fixture lands, the helper-level unit test + * PlanRegistrationHelperTests.AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot + * (eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs) + * plus the service-level call-site test + * PlanningServiceMultiShiftTests.Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly + * (same dir) cover the format-helper + aggregation contract on the + * Index → UpdatePlanRegistrationsInPeriod → AggregatePauseMinutes chain. + */ + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +test.describe('Dashboard — Samlet pause aggregation for sub-slot stamps (b1m, flag-on)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + }); + + test.skip('plannings-table cell renders correct Samlet pause for sub-slot pause when flag is on', async ({ page }) => { + // TODO(fixture): seed AssignedSite.UseOneMinuteIntervals = true on the + // worker referenced by #cell3_0 AND a PlanRegistration row with + // Pause10StartedAt = '2026-05-14T12:00:00Z' + // Pause10StoppedAt = '2026-05-14T12:03:00Z' + // on a date inside the dashboard's default visible range. + // + // Then the assertion shape is: + // + // await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + // await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + // await waitForSpinner(page); + // + // const totalBreak = page.locator('[id^="totalBreakTime"]').first(); + // await expect(totalBreak).toContainText('00:03'); + // // Negative guard — must not silently show 00:00 even when a sub-slot pause exists. + // await expect(totalBreak).not.toContainText('00:00'); + // + // The unit-test layer covers the format-helper contract: + // PlanRegistrationHelperTests.AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot + // (eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs). + }); +});