@oneuptime/common 11.3.26 → 11.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
- package/Models/DatabaseModels/IncidentMember.ts +1 -1
- package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
- package/Models/DatabaseModels/Index.ts +3 -0
- package/Models/DatabaseModels/MigrationFailure.ts +170 -0
- package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
- package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
- package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
- package/Server/API/AlertAPI.ts +1 -1
- package/Server/API/IncidentAPI.ts +2 -2
- package/Server/API/IncidentEpisodeAPI.ts +1 -1
- package/Server/API/LlmProviderAPI.ts +1 -0
- package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Infrastructure/PostgresDatabase.ts +11 -0
- package/Server/Services/AIService.ts +74 -2
- package/Server/Services/AnalyticsDatabaseService.ts +10 -1
- package/Server/Services/DatabaseService.ts +10 -1
- package/Server/Services/IncidentService.ts +1 -1
- package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
- package/Server/Services/IncomingCallPolicyService.ts +42 -1
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/MigrationFailureService.ts +9 -0
- package/Server/Services/MonitorProbeService.ts +33 -7
- package/Server/Services/MonitorService.ts +19 -7
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
- package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
- package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
- package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
- package/Server/Services/TeamMemberService.ts +6 -0
- package/Server/Services/UserNotificationRuleService.ts +13 -42
- package/Server/Services/UserOnCallLogService.ts +90 -0
- package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
- package/Server/Types/Database/QueryUtil.ts +57 -0
- package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +14 -2
- package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
- package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
- package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +183 -1
- package/Server/Utils/AI/Toolbox/Index.ts +4 -1
- package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
- package/Server/Utils/AI/Toolbox/Serializer.ts +42 -0
- package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
- package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
- package/Server/Utils/LLM/LLMService.ts +110 -11
- package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
- package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
- package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
- package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
- package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
- package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
- package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +78 -0
- package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +68 -0
- package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
- package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
- package/Tests/Types/Events/Recurring.test.ts +22 -9
- package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
- package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
- package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
- package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
- package/Types/Date.ts +200 -15
- package/Types/OnCallDutyPolicy/Layer.ts +790 -260
- package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
- package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
- package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
- package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
- package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
- package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
- package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
- package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
- package/build/dist/Server/API/AlertAPI.js +1 -1
- package/build/dist/Server/API/IncidentAPI.js +2 -2
- package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
- package/build/dist/Server/API/LlmProviderAPI.js +1 -0
- package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
- package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
- package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
- package/build/dist/Server/Services/AIService.js +53 -2
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +10 -1
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
- package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
- package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MigrationFailureService.js +9 -0
- package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
- package/build/dist/Server/Services/MonitorProbeService.js +33 -7
- package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +12 -5
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
- package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
- package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +6 -0
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
- package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
- package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
- package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
- package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +13 -2
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
- package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
- package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
- package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +152 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +4 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
- package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +40 -0
- package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -1
- package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
- package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
- package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
- package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
- package/build/dist/Server/Utils/LLM/LLMService.js +81 -5
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
- package/build/dist/Types/Date.js +153 -15
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
- package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
- package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
- package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
- package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
- package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
- package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXHAUSTIVE coverage for DAILY-restriction trimming (getEventsByDailyRestriction)
|
|
3
|
+
* and its integration through LayerUtil.getEvents / getMultiLayerEvents.
|
|
4
|
+
*
|
|
5
|
+
* These tests lock in the CORRECT (post-audit-fix) behavior of the daily
|
|
6
|
+
* restriction path across every window shape and rotation combination:
|
|
7
|
+
* - normal same-day windows (09-17, and a matrix of placements)
|
|
8
|
+
* - early-morning windows (00-06)
|
|
9
|
+
* - late windows (18:00-23:59)
|
|
10
|
+
* - windows touching midnight (18:00-00:00)
|
|
11
|
+
* - overnight wrap-around windows (22-06) incl. the FIRST-DAY morning tail
|
|
12
|
+
* - multi-day rotation events (weekly & monthly rotation + a daily restriction)
|
|
13
|
+
* covering every day of the period
|
|
14
|
+
* - rotation periods that start inside / at / after the daily window
|
|
15
|
+
* - the F2 post-window evening-gap resolution returning the correct NEXT user
|
|
16
|
+
* - DST spring-forward & fall-back weeks (America/New_York) where the
|
|
17
|
+
* wall-clock window must stay stable
|
|
18
|
+
* - server-vs-schedule timezone divergence (schedule authored in another zone)
|
|
19
|
+
*
|
|
20
|
+
* Restriction boundaries authored via getDateWithCustomTime carry the run's
|
|
21
|
+
* millisecond component (e.g. 09:00:00.147), so coverage is probed at instants
|
|
22
|
+
* offset a full second from minute boundaries (":01"/":59"), never exactly at
|
|
23
|
+
* HH:MM:00.000. Timezone assertions are expressed in wall-clock via
|
|
24
|
+
* moment-timezone so they hold regardless of the process TZ the suite runs
|
|
25
|
+
* under (run this file additionally with TZ=UTC to exercise the divergence).
|
|
26
|
+
*/
|
|
27
|
+
import LayerUtil, { LayerProps } from "../../../Types/OnCallDutyPolicy/Layer";
|
|
28
|
+
import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
|
|
29
|
+
import RestrictionTimes, {
|
|
30
|
+
RestrictionType,
|
|
31
|
+
} from "../../../Types/OnCallDutyPolicy/RestrictionTimes";
|
|
32
|
+
import Recurring from "../../../Types/Events/Recurring";
|
|
33
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
34
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
35
|
+
import EventInterval from "../../../Types/Events/EventInterval";
|
|
36
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
37
|
+
import StartAndEndTime from "../../../Types/Time/StartAndEndTime";
|
|
38
|
+
import moment from "moment-timezone";
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* ---------------------------------------------------------------------------
|
|
42
|
+
* Builders / helpers (mirroring the sibling LayerUtil test files).
|
|
43
|
+
* ---------------------------------------------------------------------------
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
function user(id: string): User {
|
|
47
|
+
return {
|
|
48
|
+
id: {
|
|
49
|
+
toString: (): string => {
|
|
50
|
+
return id;
|
|
51
|
+
},
|
|
52
|
+
} as any,
|
|
53
|
+
} as User;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Local (server-zone) daily restriction authored via wall-clock H:M.
|
|
57
|
+
function dailyLocal(
|
|
58
|
+
sh: number,
|
|
59
|
+
sm: number,
|
|
60
|
+
eh: number,
|
|
61
|
+
em: number,
|
|
62
|
+
): RestrictionTimes {
|
|
63
|
+
const r: RestrictionTimes = new RestrictionTimes();
|
|
64
|
+
r.restictionType = RestrictionType.Daily;
|
|
65
|
+
r.dayRestrictionTimes = {
|
|
66
|
+
startTime: OneUptimeDate.getDateWithCustomTime({
|
|
67
|
+
hours: sh,
|
|
68
|
+
minutes: sm,
|
|
69
|
+
seconds: 0,
|
|
70
|
+
}),
|
|
71
|
+
endTime: OneUptimeDate.getDateWithCustomTime({
|
|
72
|
+
hours: eh,
|
|
73
|
+
minutes: em,
|
|
74
|
+
seconds: 0,
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
return r;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Timezone-aware daily restriction whose wall-clock is authored IN `tz`.
|
|
81
|
+
function dailyTz(
|
|
82
|
+
startIso: string,
|
|
83
|
+
endIso: string,
|
|
84
|
+
tz: string,
|
|
85
|
+
): RestrictionTimes {
|
|
86
|
+
const r: RestrictionTimes = new RestrictionTimes();
|
|
87
|
+
r.restictionType = RestrictionType.Daily;
|
|
88
|
+
r.dayRestrictionTimes = {
|
|
89
|
+
startTime: moment.tz(startIso, tz).toDate(),
|
|
90
|
+
endTime: moment.tz(endIso, tz).toDate(),
|
|
91
|
+
};
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rotationOf(t: EventInterval, c: number): Recurring {
|
|
96
|
+
const r: Recurring = new Recurring();
|
|
97
|
+
r.intervalType = t;
|
|
98
|
+
r.intervalCount = new PositiveNumber(c);
|
|
99
|
+
return r;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface LayerCfg {
|
|
103
|
+
users: string[];
|
|
104
|
+
intervalType: EventInterval;
|
|
105
|
+
intervalCount: number;
|
|
106
|
+
restriction: RestrictionTimes;
|
|
107
|
+
start: Date;
|
|
108
|
+
timezone?: string | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeLayer(cfg: LayerCfg): LayerProps {
|
|
112
|
+
const rot: Recurring = rotationOf(cfg.intervalType, cfg.intervalCount);
|
|
113
|
+
return {
|
|
114
|
+
users: cfg.users.map(user),
|
|
115
|
+
startDateTimeOfLayer: cfg.start,
|
|
116
|
+
restrictionTimes: cfg.restriction,
|
|
117
|
+
handOffTime: Recurring.getNextDateInterval(cfg.start, rot),
|
|
118
|
+
rotation: rot,
|
|
119
|
+
timezone: cfg.timezone,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function expand(layer: LayerProps, from: Date, to: Date): Array<CalendarEvent> {
|
|
124
|
+
const util: LayerUtil = new LayerUtil();
|
|
125
|
+
return util.getEvents({
|
|
126
|
+
...layer,
|
|
127
|
+
calendarStartDate: from,
|
|
128
|
+
calendarEndDate: to,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The LIVE "who is on call now / next" path: windowed, getNumberOfEvents:1.
|
|
133
|
+
function windowedNext(layer: LayerProps, at: Date): CalendarEvent | undefined {
|
|
134
|
+
const util: LayerUtil = new LayerUtil();
|
|
135
|
+
return util.getMultiLayerEvents(
|
|
136
|
+
{
|
|
137
|
+
layers: [layer],
|
|
138
|
+
calendarStartDate: at,
|
|
139
|
+
calendarEndDate: OneUptimeDate.addRemoveDays(at, 25),
|
|
140
|
+
},
|
|
141
|
+
{ getNumberOfEvents: 1 },
|
|
142
|
+
)[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Which user covers instant `t` (start <= t < end). Millisecond-precise.
|
|
146
|
+
function coveringTitle(events: Array<CalendarEvent>, t: Date): string | null {
|
|
147
|
+
for (const e of events) {
|
|
148
|
+
if (e.start.getTime() <= t.getTime() && e.end.getTime() > t.getTime()) {
|
|
149
|
+
return e.title;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// First event that starts strictly after `t` (ground-truth "next" shift).
|
|
156
|
+
function nextAfter(
|
|
157
|
+
events: Array<CalendarEvent>,
|
|
158
|
+
t: Date,
|
|
159
|
+
): CalendarEvent | undefined {
|
|
160
|
+
let best: CalendarEvent | undefined = undefined;
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
if (e.start.getTime() > t.getTime()) {
|
|
163
|
+
if (best === undefined || e.start.getTime() < best.start.getTime()) {
|
|
164
|
+
best = e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return best;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Local instant Jan (2025) day/H/M/S in the process zone.
|
|
172
|
+
function jan(day: number, h: number, m: number, s: number): Date {
|
|
173
|
+
return new Date(2025, 0, day, h, m, s);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const NY: string = "America/New_York";
|
|
177
|
+
const KOLKATA: string = "Asia/Kolkata";
|
|
178
|
+
|
|
179
|
+
function tzInstant(iso: string, tz: string): Date {
|
|
180
|
+
return moment.tz(iso, tz).toDate();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function hhmm(d: Date, tz: string): string {
|
|
184
|
+
return moment.tz(d, tz).format("HH:mm");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// A Monday, no DST anywhere in early January.
|
|
188
|
+
const MON_JAN6: Date = jan(6, 0, 0, 0);
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
* ===========================================================================
|
|
192
|
+
* GROUP A - normal same-day window 09:00-17:00 (server-local)
|
|
193
|
+
* ===========================================================================
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
describe("Daily same-day window 09:00-17:00 (local)", () => {
|
|
197
|
+
test("direct trim emits exactly one 09:00-17:00 segment per day over a multi-day event", () => {
|
|
198
|
+
const util: LayerUtil = new LayerUtil();
|
|
199
|
+
const segs: Array<StartAndEndTime> =
|
|
200
|
+
util.trimStartAndEndTimesBasedOnRestrictionTimes({
|
|
201
|
+
eventStartTime: jan(6, 0, 0, 0),
|
|
202
|
+
eventEndTime: jan(9, 0, 0, 0), // 3 full days
|
|
203
|
+
restrictionTimes: dailyLocal(9, 0, 17, 0),
|
|
204
|
+
});
|
|
205
|
+
expect(segs.length).toBe(3);
|
|
206
|
+
for (const s of segs) {
|
|
207
|
+
expect(s.startTime.getHours()).toBe(9);
|
|
208
|
+
expect(s.startTime.getMinutes()).toBe(0);
|
|
209
|
+
expect(s.endTime.getHours()).toBe(17);
|
|
210
|
+
expect(s.endTime.getMinutes()).toBe(0);
|
|
211
|
+
// each segment is strictly inside one calendar day
|
|
212
|
+
expect(s.startTime.getDate()).toBe(s.endTime.getDate());
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("daily rotation single user: covered midday, uncovered outside the window each day", () => {
|
|
217
|
+
const layer: LayerProps = makeLayer({
|
|
218
|
+
users: ["A"],
|
|
219
|
+
intervalType: EventInterval.Day,
|
|
220
|
+
intervalCount: 1,
|
|
221
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
222
|
+
start: MON_JAN6,
|
|
223
|
+
});
|
|
224
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(11, 0, 0, 0));
|
|
225
|
+
for (let d: number = 6; d <= 10; d++) {
|
|
226
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBe("A"); // noon covered
|
|
227
|
+
expect(coveringTitle(ev, jan(d, 8, 30, 0))).toBeNull(); // before
|
|
228
|
+
expect(coveringTitle(ev, jan(d, 17, 30, 0))).toBeNull(); // after
|
|
229
|
+
expect(coveringTitle(ev, jan(d, 0, 30, 0))).toBeNull(); // deep night
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("boundary instants (second-precise, robust to ms authoring noise)", () => {
|
|
234
|
+
const layer: LayerProps = makeLayer({
|
|
235
|
+
users: ["A"],
|
|
236
|
+
intervalType: EventInterval.Day,
|
|
237
|
+
intervalCount: 1,
|
|
238
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
239
|
+
start: MON_JAN6,
|
|
240
|
+
});
|
|
241
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(8, 0, 0, 0));
|
|
242
|
+
expect(coveringTitle(ev, jan(6, 8, 59, 59))).toBeNull(); // just before open
|
|
243
|
+
expect(coveringTitle(ev, jan(6, 9, 0, 1))).toBe("A"); // just after open
|
|
244
|
+
expect(coveringTitle(ev, jan(6, 16, 59, 59))).toBe("A"); // just before close
|
|
245
|
+
expect(coveringTitle(ev, jan(6, 17, 0, 1))).toBeNull(); // just after close
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
for (const users of [
|
|
249
|
+
["A", "B"],
|
|
250
|
+
["A", "B", "C"],
|
|
251
|
+
] as string[][]) {
|
|
252
|
+
test(`daily rotation ${users.length} users: correct on-call user at noon each day`, () => {
|
|
253
|
+
const layer: LayerProps = makeLayer({
|
|
254
|
+
users,
|
|
255
|
+
intervalType: EventInterval.Day,
|
|
256
|
+
intervalCount: 1,
|
|
257
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
258
|
+
start: MON_JAN6,
|
|
259
|
+
});
|
|
260
|
+
const ev: Array<CalendarEvent> = expand(
|
|
261
|
+
layer,
|
|
262
|
+
MON_JAN6,
|
|
263
|
+
jan(6 + users.length * 2, 0, 0, 0),
|
|
264
|
+
);
|
|
265
|
+
for (let i: number = 0; i < users.length * 2; i++) {
|
|
266
|
+
const expected: string = users[i % users.length] as string;
|
|
267
|
+
expect(coveringTitle(ev, jan(6 + i, 12, 0, 0))).toBe(expected);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
* ===========================================================================
|
|
275
|
+
* GROUP B - early-morning same-day window 00:00-06:00
|
|
276
|
+
* ===========================================================================
|
|
277
|
+
*/
|
|
278
|
+
|
|
279
|
+
describe("Daily early-morning window 00:00-06:00 (local)", () => {
|
|
280
|
+
test("direct trim emits one 00:00-06:00 segment per day", () => {
|
|
281
|
+
const util: LayerUtil = new LayerUtil();
|
|
282
|
+
const segs: Array<StartAndEndTime> =
|
|
283
|
+
util.trimStartAndEndTimesBasedOnRestrictionTimes({
|
|
284
|
+
eventStartTime: jan(6, 0, 0, 0),
|
|
285
|
+
eventEndTime: jan(9, 0, 0, 0),
|
|
286
|
+
restrictionTimes: dailyLocal(0, 0, 6, 0),
|
|
287
|
+
});
|
|
288
|
+
expect(segs.length).toBe(3);
|
|
289
|
+
for (const s of segs) {
|
|
290
|
+
expect(s.startTime.getHours()).toBe(0);
|
|
291
|
+
expect(s.endTime.getHours()).toBe(6);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("covered in the small hours, uncovered the rest of the day", () => {
|
|
296
|
+
const layer: LayerProps = makeLayer({
|
|
297
|
+
users: ["A"],
|
|
298
|
+
intervalType: EventInterval.Day,
|
|
299
|
+
intervalCount: 1,
|
|
300
|
+
restriction: dailyLocal(0, 0, 6, 0),
|
|
301
|
+
start: MON_JAN6,
|
|
302
|
+
});
|
|
303
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
304
|
+
for (let d: number = 6; d <= 8; d++) {
|
|
305
|
+
expect(coveringTitle(ev, jan(d, 0, 30, 0))).toBe("A");
|
|
306
|
+
expect(coveringTitle(ev, jan(d, 3, 0, 0))).toBe("A");
|
|
307
|
+
expect(coveringTitle(ev, jan(d, 5, 59, 0))).toBe("A");
|
|
308
|
+
expect(coveringTitle(ev, jan(d, 6, 0, 1))).toBeNull();
|
|
309
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBeNull();
|
|
310
|
+
expect(coveringTitle(ev, jan(d, 22, 0, 0))).toBeNull();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/*
|
|
316
|
+
* ===========================================================================
|
|
317
|
+
* GROUP C - late same-day window 18:00-23:59
|
|
318
|
+
* ===========================================================================
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
describe("Daily late window 18:00-23:59 (local)", () => {
|
|
322
|
+
test("direct trim emits one 18:00-23:59 segment per day", () => {
|
|
323
|
+
const util: LayerUtil = new LayerUtil();
|
|
324
|
+
const segs: Array<StartAndEndTime> =
|
|
325
|
+
util.trimStartAndEndTimesBasedOnRestrictionTimes({
|
|
326
|
+
eventStartTime: jan(6, 0, 0, 0),
|
|
327
|
+
eventEndTime: jan(8, 0, 0, 0),
|
|
328
|
+
restrictionTimes: dailyLocal(18, 0, 23, 59),
|
|
329
|
+
});
|
|
330
|
+
expect(segs.length).toBe(2);
|
|
331
|
+
for (const s of segs) {
|
|
332
|
+
expect(s.startTime.getHours()).toBe(18);
|
|
333
|
+
expect(s.endTime.getHours()).toBe(23);
|
|
334
|
+
expect(s.endTime.getMinutes()).toBe(59);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("covered in the evening, uncovered before 18:00 and after 23:59", () => {
|
|
339
|
+
const layer: LayerProps = makeLayer({
|
|
340
|
+
users: ["A"],
|
|
341
|
+
intervalType: EventInterval.Day,
|
|
342
|
+
intervalCount: 1,
|
|
343
|
+
restriction: dailyLocal(18, 0, 23, 59),
|
|
344
|
+
start: MON_JAN6,
|
|
345
|
+
});
|
|
346
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
347
|
+
for (let d: number = 6; d <= 8; d++) {
|
|
348
|
+
expect(coveringTitle(ev, jan(d, 18, 0, 1))).toBe("A");
|
|
349
|
+
expect(coveringTitle(ev, jan(d, 20, 0, 0))).toBe("A");
|
|
350
|
+
expect(coveringTitle(ev, jan(d, 23, 58, 0))).toBe("A");
|
|
351
|
+
expect(coveringTitle(ev, jan(d, 17, 59, 0))).toBeNull();
|
|
352
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBeNull();
|
|
353
|
+
expect(coveringTitle(ev, jan(d, 0, 30, 0))).toBeNull();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/*
|
|
359
|
+
* ===========================================================================
|
|
360
|
+
* GROUP D - overnight wrap-around window 22:00-06:00 (incl. first-day morning)
|
|
361
|
+
* ===========================================================================
|
|
362
|
+
*/
|
|
363
|
+
|
|
364
|
+
describe("Daily overnight wrap-around window 22:00-06:00 (local)", () => {
|
|
365
|
+
test("first period emits a leading start-of-day morning segment (first-day morning covered)", () => {
|
|
366
|
+
// Period starting at midnight must have its 00:00-06:00 morning tail covered.
|
|
367
|
+
const util: LayerUtil = new LayerUtil();
|
|
368
|
+
const segs: Array<StartAndEndTime> =
|
|
369
|
+
util.trimStartAndEndTimesBasedOnRestrictionTimes({
|
|
370
|
+
eventStartTime: jan(6, 0, 0, 0),
|
|
371
|
+
eventEndTime: jan(7, 0, 0, 0),
|
|
372
|
+
restrictionTimes: dailyLocal(22, 0, 6, 0),
|
|
373
|
+
});
|
|
374
|
+
// Exactly: [Jan6 00:00, Jan6 06:00] morning + [Jan6 22:00, Jan6 23:59:59] night.
|
|
375
|
+
const morning: StartAndEndTime | undefined = segs.find(
|
|
376
|
+
(s: StartAndEndTime) => {
|
|
377
|
+
return s.startTime.getHours() === 0;
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
expect(morning).toBeTruthy();
|
|
381
|
+
expect(morning?.startTime.getDate()).toBe(6);
|
|
382
|
+
expect(morning?.endTime.getHours()).toBe(6);
|
|
383
|
+
const night: StartAndEndTime | undefined = segs.find(
|
|
384
|
+
(s: StartAndEndTime) => {
|
|
385
|
+
return s.startTime.getHours() === 22;
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
expect(night).toBeTruthy();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("coverage completeness across several days for a midnight-anchored rotation", () => {
|
|
392
|
+
const layer: LayerProps = makeLayer({
|
|
393
|
+
users: ["A"],
|
|
394
|
+
intervalType: EventInterval.Day,
|
|
395
|
+
intervalCount: 1,
|
|
396
|
+
restriction: dailyLocal(22, 0, 6, 0),
|
|
397
|
+
start: MON_JAN6,
|
|
398
|
+
});
|
|
399
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
400
|
+
|
|
401
|
+
// First-day morning (the fix: loop starts one day before the event).
|
|
402
|
+
expect(coveringTitle(ev, jan(6, 0, 0, 1))).toBe("A");
|
|
403
|
+
expect(coveringTitle(ev, jan(6, 3, 0, 0))).toBe("A");
|
|
404
|
+
expect(coveringTitle(ev, jan(6, 5, 59, 0))).toBe("A");
|
|
405
|
+
|
|
406
|
+
// Nights across days.
|
|
407
|
+
for (const d of [6, 7, 8]) {
|
|
408
|
+
expect(coveringTitle(ev, jan(d, 22, 0, 1))).toBe("A");
|
|
409
|
+
expect(coveringTitle(ev, jan(d, 23, 30, 0))).toBe("A");
|
|
410
|
+
}
|
|
411
|
+
// Following mornings.
|
|
412
|
+
for (const d of [7, 8]) {
|
|
413
|
+
expect(coveringTitle(ev, jan(d, 3, 0, 0))).toBe("A");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Daytime gap is uncovered.
|
|
417
|
+
for (const d of [6, 7, 8]) {
|
|
418
|
+
expect(coveringTitle(ev, jan(d, 6, 0, 1))).toBeNull();
|
|
419
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBeNull();
|
|
420
|
+
expect(coveringTitle(ev, jan(d, 21, 59, 0))).toBeNull();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("weekly rotation with an overnight restriction covers every night of the week", () => {
|
|
425
|
+
const layer: LayerProps = makeLayer({
|
|
426
|
+
users: ["A"],
|
|
427
|
+
intervalType: EventInterval.Week,
|
|
428
|
+
intervalCount: 1,
|
|
429
|
+
restriction: dailyLocal(22, 0, 6, 0),
|
|
430
|
+
start: MON_JAN6,
|
|
431
|
+
});
|
|
432
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(12, 0, 0, 0));
|
|
433
|
+
for (let d: number = 6; d <= 10; d++) {
|
|
434
|
+
expect(coveringTitle(ev, jan(d, 0, 30, 0))).toBe("A"); // morning
|
|
435
|
+
expect(coveringTitle(ev, jan(d, 23, 0, 0))).toBe("A"); // night
|
|
436
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBeNull(); // daytime
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
for (const [sh, eh] of [
|
|
441
|
+
[23, 5],
|
|
442
|
+
[20, 8],
|
|
443
|
+
[21, 7],
|
|
444
|
+
] as Array<[number, number]>) {
|
|
445
|
+
test(`overnight ${sh}:00-${eh}:00 covers the wrap band and excludes the daytime`, () => {
|
|
446
|
+
const layer: LayerProps = makeLayer({
|
|
447
|
+
users: ["A"],
|
|
448
|
+
intervalType: EventInterval.Day,
|
|
449
|
+
intervalCount: 1,
|
|
450
|
+
restriction: dailyLocal(sh, 0, eh, 0),
|
|
451
|
+
start: MON_JAN6,
|
|
452
|
+
});
|
|
453
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
454
|
+
// Deep night and deep early-morning covered; solar midday uncovered.
|
|
455
|
+
expect(coveringTitle(ev, jan(7, 3, 0, 0))).toBe("A");
|
|
456
|
+
expect(coveringTitle(ev, jan(7, sh, 30, 0))).toBe("A");
|
|
457
|
+
expect(coveringTitle(ev, jan(7, 12, 0, 0))).toBeNull();
|
|
458
|
+
// A point strictly between eh and sh (mid-afternoon) is uncovered.
|
|
459
|
+
const mid: number = Math.floor((eh + sh) / 2);
|
|
460
|
+
expect(coveringTitle(ev, jan(7, mid, 30, 0))).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/*
|
|
466
|
+
* ===========================================================================
|
|
467
|
+
* GROUP E - window touching midnight (18:00-00:00 == overnight ending at 00:00)
|
|
468
|
+
* ===========================================================================
|
|
469
|
+
*/
|
|
470
|
+
|
|
471
|
+
describe("Daily window touching midnight 18:00-00:00 (local)", () => {
|
|
472
|
+
test("covers 18:00 to end-of-day with no phantom morning segment", () => {
|
|
473
|
+
const layer: LayerProps = makeLayer({
|
|
474
|
+
users: ["A"],
|
|
475
|
+
intervalType: EventInterval.Day,
|
|
476
|
+
intervalCount: 1,
|
|
477
|
+
restriction: dailyLocal(18, 0, 0, 0),
|
|
478
|
+
start: MON_JAN6,
|
|
479
|
+
});
|
|
480
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
481
|
+
for (const d of [6, 7, 8]) {
|
|
482
|
+
expect(coveringTitle(ev, jan(d, 18, 0, 1))).toBe("A");
|
|
483
|
+
expect(coveringTitle(ev, jan(d, 23, 59, 30))).toBe("A");
|
|
484
|
+
// No coverage in the morning/day: the wrap "morning" is zero-length.
|
|
485
|
+
expect(coveringTitle(ev, jan(d, 0, 30, 0))).toBeNull();
|
|
486
|
+
expect(coveringTitle(ev, jan(d, 3, 0, 0))).toBeNull();
|
|
487
|
+
expect(coveringTitle(ev, jan(d, 12, 0, 0))).toBeNull();
|
|
488
|
+
expect(coveringTitle(ev, jan(d, 17, 59, 0))).toBeNull();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* ===========================================================================
|
|
495
|
+
* GROUP F - multi-day rotation events (weekly & monthly) with a daily restriction
|
|
496
|
+
* ===========================================================================
|
|
497
|
+
*/
|
|
498
|
+
|
|
499
|
+
describe("Multi-day rotation events with a daily 09:00-17:00 restriction", () => {
|
|
500
|
+
test("weekly rotation, 2 users: every day of week 1 is A, every day of week 2 is B", () => {
|
|
501
|
+
const layer: LayerProps = makeLayer({
|
|
502
|
+
users: ["A", "B"],
|
|
503
|
+
intervalType: EventInterval.Week,
|
|
504
|
+
intervalCount: 1,
|
|
505
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
506
|
+
start: MON_JAN6,
|
|
507
|
+
});
|
|
508
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(20, 0, 0, 0));
|
|
509
|
+
for (let d: number = 0; d < 7; d++) {
|
|
510
|
+
expect(coveringTitle(ev, jan(6 + d, 12, 0, 0))).toBe("A");
|
|
511
|
+
expect(coveringTitle(ev, jan(13 + d, 12, 0, 0))).toBe("B");
|
|
512
|
+
// Off-hours still uncovered even mid-period.
|
|
513
|
+
expect(coveringTitle(ev, jan(6 + d, 8, 30, 0))).toBeNull();
|
|
514
|
+
expect(coveringTitle(ev, jan(6 + d, 18, 0, 0))).toBeNull();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("weekly rotation, 3 users: consecutive weeks are A, B, C", () => {
|
|
519
|
+
const layer: LayerProps = makeLayer({
|
|
520
|
+
users: ["A", "B", "C"],
|
|
521
|
+
intervalType: EventInterval.Week,
|
|
522
|
+
intervalCount: 1,
|
|
523
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
524
|
+
start: MON_JAN6,
|
|
525
|
+
});
|
|
526
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(27, 0, 0, 0));
|
|
527
|
+
expect(coveringTitle(ev, jan(8, 12, 0, 0))).toBe("A"); // week 1
|
|
528
|
+
expect(coveringTitle(ev, jan(15, 12, 0, 0))).toBe("B"); // week 2
|
|
529
|
+
expect(coveringTitle(ev, jan(22, 12, 0, 0))).toBe("C"); // week 3
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("weekly rotation x2 (fortnightly): first two weeks A, next two weeks B", () => {
|
|
533
|
+
const layer: LayerProps = makeLayer({
|
|
534
|
+
users: ["A", "B"],
|
|
535
|
+
intervalType: EventInterval.Week,
|
|
536
|
+
intervalCount: 2,
|
|
537
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
538
|
+
start: MON_JAN6,
|
|
539
|
+
});
|
|
540
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(34, 0, 0, 0));
|
|
541
|
+
expect(coveringTitle(ev, jan(8, 12, 0, 0))).toBe("A"); // wk1
|
|
542
|
+
expect(coveringTitle(ev, jan(15, 12, 0, 0))).toBe("A"); // wk2
|
|
543
|
+
expect(coveringTitle(ev, jan(22, 12, 0, 0))).toBe("B"); // wk3
|
|
544
|
+
expect(coveringTitle(ev, jan(29, 12, 0, 0))).toBe("B"); // wk4
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("monthly rotation, 2 users: Jan is A, Feb is B, Mar is A (mid-month sampling)", () => {
|
|
548
|
+
const layer: LayerProps = makeLayer({
|
|
549
|
+
users: ["A", "B"],
|
|
550
|
+
intervalType: EventInterval.Month,
|
|
551
|
+
intervalCount: 1,
|
|
552
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
553
|
+
start: new Date(2026, 0, 1, 0, 0, 0),
|
|
554
|
+
});
|
|
555
|
+
const ev: Array<CalendarEvent> = expand(
|
|
556
|
+
layer,
|
|
557
|
+
new Date(2026, 0, 1, 0, 0, 0),
|
|
558
|
+
new Date(2026, 3, 1, 0, 0, 0),
|
|
559
|
+
);
|
|
560
|
+
expect(coveringTitle(ev, new Date(2026, 0, 15, 12, 0, 0))).toBe("A");
|
|
561
|
+
expect(coveringTitle(ev, new Date(2026, 0, 3, 12, 0, 0))).toBe("A");
|
|
562
|
+
expect(coveringTitle(ev, new Date(2026, 1, 15, 12, 0, 0))).toBe("B");
|
|
563
|
+
expect(coveringTitle(ev, new Date(2026, 2, 15, 12, 0, 0))).toBe("A");
|
|
564
|
+
// Off-hours uncovered inside each month.
|
|
565
|
+
expect(coveringTitle(ev, new Date(2026, 1, 15, 8, 30, 0))).toBeNull();
|
|
566
|
+
expect(coveringTitle(ev, new Date(2026, 1, 15, 18, 0, 0))).toBeNull();
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/*
|
|
571
|
+
* ===========================================================================
|
|
572
|
+
* GROUP G - rotation periods that start inside / at / after the daily window
|
|
573
|
+
* ===========================================================================
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
describe("Rotation period start relative to the daily window", () => {
|
|
577
|
+
test("start INSIDE the window (12:00): first day trims to [12:00,17:00], last day [09:00,12:00]", () => {
|
|
578
|
+
const layer: LayerProps = makeLayer({
|
|
579
|
+
users: ["A", "B"],
|
|
580
|
+
intervalType: EventInterval.Day,
|
|
581
|
+
intervalCount: 1,
|
|
582
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
583
|
+
start: jan(6, 12, 0, 0),
|
|
584
|
+
});
|
|
585
|
+
const ev: Array<CalendarEvent> = expand(
|
|
586
|
+
layer,
|
|
587
|
+
jan(6, 12, 0, 0),
|
|
588
|
+
jan(9, 0, 0, 0),
|
|
589
|
+
);
|
|
590
|
+
// Period 1 (A): partial first afternoon + partial last morning.
|
|
591
|
+
expect(coveringTitle(ev, jan(6, 15, 0, 0))).toBe("A");
|
|
592
|
+
expect(coveringTitle(ev, jan(7, 10, 0, 0))).toBe("A");
|
|
593
|
+
// Period 2 (B) begins at Jan7 12:00.
|
|
594
|
+
expect(coveringTitle(ev, jan(7, 13, 0, 0))).toBe("B");
|
|
595
|
+
// Gaps.
|
|
596
|
+
expect(coveringTitle(ev, jan(6, 18, 0, 0))).toBeNull();
|
|
597
|
+
expect(coveringTitle(ev, jan(7, 8, 0, 0))).toBeNull();
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("start AT the window open (09:00): first day covered from 09:00", () => {
|
|
601
|
+
const layer: LayerProps = makeLayer({
|
|
602
|
+
users: ["A", "B"],
|
|
603
|
+
intervalType: EventInterval.Day,
|
|
604
|
+
intervalCount: 1,
|
|
605
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
606
|
+
start: jan(6, 9, 0, 0),
|
|
607
|
+
});
|
|
608
|
+
const ev: Array<CalendarEvent> = expand(
|
|
609
|
+
layer,
|
|
610
|
+
jan(6, 9, 0, 0),
|
|
611
|
+
jan(8, 0, 0, 0),
|
|
612
|
+
);
|
|
613
|
+
expect(coveringTitle(ev, jan(6, 9, 0, 30))).toBe("A");
|
|
614
|
+
expect(coveringTitle(ev, jan(6, 16, 59, 0))).toBe("A");
|
|
615
|
+
expect(coveringTitle(ev, jan(6, 17, 0, 1))).toBeNull();
|
|
616
|
+
expect(coveringTitle(ev, jan(7, 12, 0, 0))).toBe("B");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("start AFTER the window close (20:00): first calendar day has no coverage, next day is still this user's turn", () => {
|
|
620
|
+
const layer: LayerProps = makeLayer({
|
|
621
|
+
users: ["A", "B"],
|
|
622
|
+
intervalType: EventInterval.Day,
|
|
623
|
+
intervalCount: 1,
|
|
624
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
625
|
+
start: jan(6, 20, 0, 0),
|
|
626
|
+
});
|
|
627
|
+
const ev: Array<CalendarEvent> = expand(
|
|
628
|
+
layer,
|
|
629
|
+
jan(6, 20, 0, 0),
|
|
630
|
+
jan(9, 0, 0, 0),
|
|
631
|
+
);
|
|
632
|
+
// Nothing on Jan6 evening (window already closed).
|
|
633
|
+
expect(coveringTitle(ev, jan(6, 22, 0, 0))).toBeNull();
|
|
634
|
+
// Period 1 (A) still covers its next day (Jan7); period 2 (B) covers Jan8.
|
|
635
|
+
expect(coveringTitle(ev, jan(7, 12, 0, 0))).toBe("A");
|
|
636
|
+
expect(coveringTitle(ev, jan(8, 12, 0, 0))).toBe("B");
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
/*
|
|
641
|
+
* ===========================================================================
|
|
642
|
+
* GROUP H - F2: post-window evening gap resolves the correct NEXT covered user
|
|
643
|
+
* ===========================================================================
|
|
644
|
+
*/
|
|
645
|
+
|
|
646
|
+
describe("F2 post-window gap: windowed 'next' matches the full-expansion next shift", () => {
|
|
647
|
+
const counts: number[] = [1, 2, 3];
|
|
648
|
+
const userSets: string[][] = [
|
|
649
|
+
["A", "B"],
|
|
650
|
+
["A", "B", "C"],
|
|
651
|
+
];
|
|
652
|
+
|
|
653
|
+
for (const count of counts) {
|
|
654
|
+
for (const users of userSets) {
|
|
655
|
+
test(`x${count} daily, ${users.length} users, 09-17: evening gaps never go off-by-one over 10 days`, () => {
|
|
656
|
+
const layer: LayerProps = makeLayer({
|
|
657
|
+
users,
|
|
658
|
+
intervalType: EventInterval.Day,
|
|
659
|
+
intervalCount: count,
|
|
660
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
661
|
+
start: MON_JAN6,
|
|
662
|
+
});
|
|
663
|
+
const full: Array<CalendarEvent> = expand(
|
|
664
|
+
layer,
|
|
665
|
+
MON_JAN6,
|
|
666
|
+
jan(6 + 14, 0, 0, 0),
|
|
667
|
+
);
|
|
668
|
+
const mismatches: string[] = [];
|
|
669
|
+
for (let d: number = 0; d < 10; d++) {
|
|
670
|
+
const gap: Date = jan(6 + d, 20, 0, 0); // evening, window closed
|
|
671
|
+
const expected: CalendarEvent | undefined = nextAfter(full, gap);
|
|
672
|
+
const got: CalendarEvent | undefined = windowedNext(layer, gap);
|
|
673
|
+
if ((got?.title ?? null) !== (expected?.title ?? null)) {
|
|
674
|
+
mismatches.push(
|
|
675
|
+
`day ${6 + d}: windowed=${got?.title ?? null} expectedNext=${expected?.title ?? null}`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
expect(mismatches).toEqual([]);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
test("x1 daily [A,B]: evening gaps resolve to next day's user (B,A,B,A...)", () => {
|
|
685
|
+
const layer: LayerProps = makeLayer({
|
|
686
|
+
users: ["A", "B"],
|
|
687
|
+
intervalType: EventInterval.Day,
|
|
688
|
+
intervalCount: 1,
|
|
689
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
690
|
+
start: MON_JAN6,
|
|
691
|
+
});
|
|
692
|
+
expect(windowedNext(layer, jan(6, 20, 0, 0))?.title).toBe("B");
|
|
693
|
+
expect(windowedNext(layer, jan(7, 20, 0, 0))?.title).toBe("A");
|
|
694
|
+
expect(windowedNext(layer, jan(8, 20, 0, 0))?.title).toBe("B");
|
|
695
|
+
expect(windowedNext(layer, jan(9, 20, 0, 0))?.title).toBe("A");
|
|
696
|
+
// And the resolved next event starts the following morning.
|
|
697
|
+
const n: CalendarEvent | undefined = windowedNext(layer, jan(6, 20, 0, 0));
|
|
698
|
+
expect(n?.start.getDate()).toBe(7);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("x2 daily [A,B]: last covered day evening advances, earlier day stays in the period", () => {
|
|
702
|
+
const layer: LayerProps = makeLayer({
|
|
703
|
+
users: ["A", "B"],
|
|
704
|
+
intervalType: EventInterval.Day,
|
|
705
|
+
intervalCount: 2,
|
|
706
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
707
|
+
start: MON_JAN6,
|
|
708
|
+
});
|
|
709
|
+
/*
|
|
710
|
+
* Period 1 = Jan6..Jan7 = A; period 2 = Jan8..Jan9 = B.
|
|
711
|
+
* Jan6 evening: still A's period, next covered shift is Jan7 (A).
|
|
712
|
+
*/
|
|
713
|
+
expect(windowedNext(layer, jan(6, 20, 0, 0))?.title).toBe("A");
|
|
714
|
+
// Jan7 evening: A's period is over, next covered shift is Jan8 (B).
|
|
715
|
+
expect(windowedNext(layer, jan(7, 20, 0, 0))?.title).toBe("B");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("non-gap resolution: during/before the window returns the CURRENT day's user", () => {
|
|
719
|
+
const layer: LayerProps = makeLayer({
|
|
720
|
+
users: ["A", "B"],
|
|
721
|
+
intervalType: EventInterval.Day,
|
|
722
|
+
intervalCount: 1,
|
|
723
|
+
restriction: dailyLocal(9, 0, 17, 0),
|
|
724
|
+
start: MON_JAN6,
|
|
725
|
+
});
|
|
726
|
+
expect(windowedNext(layer, jan(6, 8, 0, 0))?.title).toBe("A"); // before open
|
|
727
|
+
expect(windowedNext(layer, jan(6, 12, 0, 0))?.title).toBe("A"); // inside
|
|
728
|
+
expect(windowedNext(layer, jan(7, 12, 0, 0))?.title).toBe("B"); // inside next day
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
/*
|
|
733
|
+
* ===========================================================================
|
|
734
|
+
* GROUP I - DST spring-forward & fall-back + server-vs-schedule divergence
|
|
735
|
+
* ===========================================================================
|
|
736
|
+
*/
|
|
737
|
+
|
|
738
|
+
describe("DST wall-clock stability (America/New_York, schedule zone)", () => {
|
|
739
|
+
test("09:00-17:00 stays 09:00-17:00 across the spring-forward week (Sun 2026-03-08)", () => {
|
|
740
|
+
const start: Date = tzInstant("2026-03-06 00:00", NY); // Fri
|
|
741
|
+
const layer: LayerProps = {
|
|
742
|
+
users: [user("A")],
|
|
743
|
+
startDateTimeOfLayer: start,
|
|
744
|
+
handOffTime: start,
|
|
745
|
+
restrictionTimes: dailyTz("2026-03-06 09:00", "2026-03-06 17:00", NY),
|
|
746
|
+
rotation: rotationOf(EventInterval.Day, 1),
|
|
747
|
+
timezone: NY,
|
|
748
|
+
};
|
|
749
|
+
const ev: Array<CalendarEvent> = expand(
|
|
750
|
+
layer,
|
|
751
|
+
start,
|
|
752
|
+
tzInstant("2026-03-12 00:00", NY),
|
|
753
|
+
);
|
|
754
|
+
const opens: Array<CalendarEvent> = ev.filter((e: CalendarEvent) => {
|
|
755
|
+
return hhmm(e.start, NY) === "09:00";
|
|
756
|
+
});
|
|
757
|
+
expect(opens.length).toBeGreaterThanOrEqual(5); // Fri..Wed
|
|
758
|
+
for (const w of opens) {
|
|
759
|
+
expect(hhmm(w.start, NY)).toBe("09:00");
|
|
760
|
+
expect(hhmm(w.end, NY)).toBe("17:00");
|
|
761
|
+
}
|
|
762
|
+
// Concrete coverage on the DST-transition Sunday.
|
|
763
|
+
expect(coveringTitle(ev, tzInstant("2026-03-08 12:00", NY))).toBe("A");
|
|
764
|
+
expect(coveringTitle(ev, tzInstant("2026-03-08 08:00", NY))).toBeNull();
|
|
765
|
+
expect(coveringTitle(ev, tzInstant("2026-03-08 18:00", NY))).toBeNull();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("09:00-17:00 stays 09:00-17:00 across the fall-back week (Sun 2026-11-01)", () => {
|
|
769
|
+
const start: Date = tzInstant("2026-10-30 00:00", NY); // Fri
|
|
770
|
+
const layer: LayerProps = {
|
|
771
|
+
users: [user("A")],
|
|
772
|
+
startDateTimeOfLayer: start,
|
|
773
|
+
handOffTime: start,
|
|
774
|
+
restrictionTimes: dailyTz("2026-10-30 09:00", "2026-10-30 17:00", NY),
|
|
775
|
+
rotation: rotationOf(EventInterval.Day, 1),
|
|
776
|
+
timezone: NY,
|
|
777
|
+
};
|
|
778
|
+
const ev: Array<CalendarEvent> = expand(
|
|
779
|
+
layer,
|
|
780
|
+
start,
|
|
781
|
+
tzInstant("2026-11-04 00:00", NY),
|
|
782
|
+
);
|
|
783
|
+
const opens: Array<CalendarEvent> = ev.filter((e: CalendarEvent) => {
|
|
784
|
+
return hhmm(e.start, NY) === "09:00";
|
|
785
|
+
});
|
|
786
|
+
expect(opens.length).toBeGreaterThanOrEqual(5);
|
|
787
|
+
for (const w of opens) {
|
|
788
|
+
expect(hhmm(w.end, NY)).toBe("17:00");
|
|
789
|
+
}
|
|
790
|
+
expect(coveringTitle(ev, tzInstant("2026-11-01 12:00", NY))).toBe("A");
|
|
791
|
+
expect(coveringTitle(ev, tzInstant("2026-11-01 08:00", NY))).toBeNull();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("overnight 22:00-06:00 keeps its wall-clock band across spring-forward", () => {
|
|
795
|
+
const start: Date = tzInstant("2026-03-06 00:00", NY);
|
|
796
|
+
const layer: LayerProps = {
|
|
797
|
+
users: [user("A")],
|
|
798
|
+
startDateTimeOfLayer: start,
|
|
799
|
+
handOffTime: start,
|
|
800
|
+
restrictionTimes: dailyTz("2026-03-06 22:00", "2026-03-06 06:00", NY),
|
|
801
|
+
rotation: rotationOf(EventInterval.Day, 1),
|
|
802
|
+
timezone: NY,
|
|
803
|
+
};
|
|
804
|
+
const ev: Array<CalendarEvent> = expand(
|
|
805
|
+
layer,
|
|
806
|
+
start,
|
|
807
|
+
tzInstant("2026-03-10 00:00", NY),
|
|
808
|
+
);
|
|
809
|
+
// Morning tails close at 06:00 NY, nights open at 22:00 NY.
|
|
810
|
+
const mornings: Array<CalendarEvent> = ev.filter((e: CalendarEvent) => {
|
|
811
|
+
return hhmm(e.start, NY) === "00:00";
|
|
812
|
+
});
|
|
813
|
+
const nights: Array<CalendarEvent> = ev.filter((e: CalendarEvent) => {
|
|
814
|
+
return hhmm(e.start, NY) === "22:00";
|
|
815
|
+
});
|
|
816
|
+
expect(mornings.length).toBeGreaterThanOrEqual(3);
|
|
817
|
+
expect(nights.length).toBeGreaterThanOrEqual(3);
|
|
818
|
+
for (const m of mornings) {
|
|
819
|
+
expect(hhmm(m.end, NY)).toBe("06:00");
|
|
820
|
+
}
|
|
821
|
+
// Coverage on the DST Sunday: 03:00 NY (morning) covered, midday uncovered.
|
|
822
|
+
expect(coveringTitle(ev, tzInstant("2026-03-08 03:00", NY))).toBe("A");
|
|
823
|
+
expect(coveringTitle(ev, tzInstant("2026-03-07 23:00", NY))).toBe("A");
|
|
824
|
+
expect(coveringTitle(ev, tzInstant("2026-03-08 12:00", NY))).toBeNull();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("server-vs-schedule divergence: a Kolkata schedule resolves to its own wall-clock", () => {
|
|
828
|
+
// Authored + resolved in Asia/Kolkata (UTC+5:30, no DST); process zone differs.
|
|
829
|
+
const start: Date = tzInstant("2026-02-16 00:00", KOLKATA);
|
|
830
|
+
const layer: LayerProps = {
|
|
831
|
+
users: [user("A")],
|
|
832
|
+
startDateTimeOfLayer: start,
|
|
833
|
+
handOffTime: start,
|
|
834
|
+
restrictionTimes: dailyTz(
|
|
835
|
+
"2026-02-16 09:30",
|
|
836
|
+
"2026-02-16 18:30",
|
|
837
|
+
KOLKATA,
|
|
838
|
+
),
|
|
839
|
+
rotation: rotationOf(EventInterval.Day, 1),
|
|
840
|
+
timezone: KOLKATA,
|
|
841
|
+
};
|
|
842
|
+
const ev: Array<CalendarEvent> = expand(
|
|
843
|
+
layer,
|
|
844
|
+
start,
|
|
845
|
+
tzInstant("2026-02-19 00:00", KOLKATA),
|
|
846
|
+
);
|
|
847
|
+
const opens: Array<CalendarEvent> = ev.filter((e: CalendarEvent) => {
|
|
848
|
+
return hhmm(e.start, KOLKATA) === "09:30";
|
|
849
|
+
});
|
|
850
|
+
expect(opens.length).toBeGreaterThanOrEqual(2);
|
|
851
|
+
for (const w of opens) {
|
|
852
|
+
expect(hhmm(w.end, KOLKATA)).toBe("18:30");
|
|
853
|
+
}
|
|
854
|
+
expect(coveringTitle(ev, tzInstant("2026-02-17 12:00", KOLKATA))).toBe("A");
|
|
855
|
+
expect(
|
|
856
|
+
coveringTitle(ev, tzInstant("2026-02-17 08:00", KOLKATA)),
|
|
857
|
+
).toBeNull();
|
|
858
|
+
expect(
|
|
859
|
+
coveringTitle(ev, tzInstant("2026-02-17 20:00", KOLKATA)),
|
|
860
|
+
).toBeNull();
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
/*
|
|
865
|
+
* ===========================================================================
|
|
866
|
+
* GROUP J - exhaustive matrix of same-day window placements
|
|
867
|
+
* ===========================================================================
|
|
868
|
+
*/
|
|
869
|
+
|
|
870
|
+
describe("Exhaustive same-day window placements (local, daily rotation)", () => {
|
|
871
|
+
const windows: Array<[number, number]> = [
|
|
872
|
+
[1, 5],
|
|
873
|
+
[6, 10],
|
|
874
|
+
[8, 12],
|
|
875
|
+
[9, 17],
|
|
876
|
+
[10, 14],
|
|
877
|
+
[13, 20],
|
|
878
|
+
[19, 23],
|
|
879
|
+
];
|
|
880
|
+
|
|
881
|
+
for (const [sh, eh] of windows) {
|
|
882
|
+
test(`${sh}:00-${eh}:00 covers its band and excludes the shoulders`, () => {
|
|
883
|
+
const layer: LayerProps = makeLayer({
|
|
884
|
+
users: ["A"],
|
|
885
|
+
intervalType: EventInterval.Day,
|
|
886
|
+
intervalCount: 1,
|
|
887
|
+
restriction: dailyLocal(sh, 0, eh, 0),
|
|
888
|
+
start: MON_JAN6,
|
|
889
|
+
});
|
|
890
|
+
const ev: Array<CalendarEvent> = expand(layer, MON_JAN6, jan(9, 0, 0, 0));
|
|
891
|
+
for (const d of [6, 7, 8]) {
|
|
892
|
+
const mid: number = Math.floor((sh + eh) / 2);
|
|
893
|
+
expect(coveringTitle(ev, jan(d, mid, 30, 0))).toBe("A"); // inside
|
|
894
|
+
expect(coveringTitle(ev, jan(d, sh, 0, 1))).toBe("A"); // just after open
|
|
895
|
+
expect(coveringTitle(ev, jan(d, eh, 0, 1))).toBeNull(); // just after close
|
|
896
|
+
expect(coveringTitle(ev, jan(d, sh - 1, 30, 0))).toBeNull(); // before open
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
});
|