@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,863 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXHAUSTIVE differential: the WINDOWED live current/next resolution must agree
|
|
3
|
+
* with a FULL expansion from layer start, across a broad config matrix.
|
|
4
|
+
*
|
|
5
|
+
* The live "who is on call now / next" path never expands from the layer's
|
|
6
|
+
* birthday; it opens a calendar window at the CURRENT instant and asks for the
|
|
7
|
+
* first (getNumberOfEvents:1) or first-two (getNumberOfEvents:2) events. That
|
|
8
|
+
* paging path is exercised two ways here:
|
|
9
|
+
* - LayerUtil.getEvents(now .. now+horizon, {getNumberOfEvents}) (per-layer)
|
|
10
|
+
* - LayerUtil.getMultiLayerEvents(now .. now+small, {getNumberOfEvents}) (roster)
|
|
11
|
+
*
|
|
12
|
+
* ORACLE: a full expansion from the layer start over a generous horizon is the
|
|
13
|
+
* ground truth. For any instant `at`, the on-call user resolved by the windowed
|
|
14
|
+
* path must equal the title of the EARLIEST full-expansion event whose end is
|
|
15
|
+
* after `at` — i.e. the event that COVERS `at` when `at` is inside a coverage
|
|
16
|
+
* window, or the NEXT covered event when `at` falls in a restriction gap. The
|
|
17
|
+
* first two windowed titles must match the first two such full events.
|
|
18
|
+
*
|
|
19
|
+
* The matrix is intervals x rotationCounts x userCounts x
|
|
20
|
+
* {no restriction, daily 09-17, weekly Mon-Fri} x tz {local, NY, Kolkata}.
|
|
21
|
+
*
|
|
22
|
+
* Sampling: instants are the MIDPOINTS of real full-expansion events (guaranteed
|
|
23
|
+
* strictly interior => off-boundary) and the MIDPOINTS of real restriction gaps
|
|
24
|
+
* (> 5 minutes, so the +/-1s rotation-boundary stitch is never sampled — that
|
|
25
|
+
* stitch is a known display artifact, not a paging bug, per the task).
|
|
26
|
+
*
|
|
27
|
+
* The suite is fully differential, so every assertion holds regardless of the
|
|
28
|
+
* process timezone; it is intended to be run under the default zone AND under
|
|
29
|
+
* TZ=UTC to exercise server-vs-schedule divergence.
|
|
30
|
+
*/
|
|
31
|
+
import LayerUtil, { LayerProps } from "../../../Types/OnCallDutyPolicy/Layer";
|
|
32
|
+
import CalendarEvent from "../../../Types/Calendar/CalendarEvent";
|
|
33
|
+
import RestrictionTimes, {
|
|
34
|
+
RestrictionType,
|
|
35
|
+
WeeklyResctriction,
|
|
36
|
+
} from "../../../Types/OnCallDutyPolicy/RestrictionTimes";
|
|
37
|
+
import Recurring from "../../../Types/Events/Recurring";
|
|
38
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
39
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
40
|
+
import EventInterval from "../../../Types/Events/EventInterval";
|
|
41
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
42
|
+
import DayOfWeek from "../../../Types/Day/DayOfWeek";
|
|
43
|
+
|
|
44
|
+
function user(id: string): User {
|
|
45
|
+
return {
|
|
46
|
+
id: {
|
|
47
|
+
toString: (): string => {
|
|
48
|
+
return id;
|
|
49
|
+
},
|
|
50
|
+
} as any,
|
|
51
|
+
} as User;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function noRestriction(): RestrictionTimes {
|
|
55
|
+
const r: RestrictionTimes = new RestrictionTimes();
|
|
56
|
+
r.restictionType = RestrictionType.None;
|
|
57
|
+
r.dayRestrictionTimes = null;
|
|
58
|
+
return r;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dailyRestriction(
|
|
62
|
+
startHour: number,
|
|
63
|
+
endHour: number,
|
|
64
|
+
): RestrictionTimes {
|
|
65
|
+
const r: RestrictionTimes = new RestrictionTimes();
|
|
66
|
+
r.restictionType = RestrictionType.Daily;
|
|
67
|
+
r.dayRestrictionTimes = {
|
|
68
|
+
startTime: OneUptimeDate.getDateWithCustomTime({
|
|
69
|
+
hours: startHour,
|
|
70
|
+
minutes: 0,
|
|
71
|
+
seconds: 0,
|
|
72
|
+
}),
|
|
73
|
+
endTime: OneUptimeDate.getDateWithCustomTime({
|
|
74
|
+
hours: endHour,
|
|
75
|
+
minutes: 0,
|
|
76
|
+
seconds: 0,
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Weekly "Monday through Friday" coverage: a single non-wrapping weekly window
|
|
84
|
+
* from Monday 00:00 to Saturday 00:00, leaving Saturday & Sunday as a gap. The
|
|
85
|
+
* anchor dates below are a real Monday / Saturday; getWeeklyRestrictionTimesForWeek
|
|
86
|
+
* re-derives the day-of-week (in the schedule zone) and moves the window into the
|
|
87
|
+
* event's week, so the calendar dates are only day-of-week anchors.
|
|
88
|
+
*/
|
|
89
|
+
function weeklyMondayToFriday(): RestrictionTimes {
|
|
90
|
+
const weekly: WeeklyResctriction = {
|
|
91
|
+
startDay: DayOfWeek.Monday,
|
|
92
|
+
endDay: DayOfWeek.Saturday,
|
|
93
|
+
startTime: OneUptimeDate.fromString("2025-01-06T00:00:00.000Z"), // Monday
|
|
94
|
+
endTime: OneUptimeDate.fromString("2025-01-11T00:00:00.000Z"), // Saturday
|
|
95
|
+
};
|
|
96
|
+
const r: RestrictionTimes = new RestrictionTimes();
|
|
97
|
+
r.restictionType = RestrictionType.Weekly;
|
|
98
|
+
r.weeklyRestrictionTimes = [weekly];
|
|
99
|
+
return r;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type RestrictionKind = "none" | "daily09-17" | "weeklyMonFri";
|
|
103
|
+
|
|
104
|
+
function buildRestriction(kind: RestrictionKind): RestrictionTimes {
|
|
105
|
+
switch (kind) {
|
|
106
|
+
case "none":
|
|
107
|
+
return noRestriction();
|
|
108
|
+
case "daily09-17":
|
|
109
|
+
return dailyRestriction(9, 17);
|
|
110
|
+
case "weeklyMonFri":
|
|
111
|
+
return weeklyMondayToFriday();
|
|
112
|
+
default:
|
|
113
|
+
return noRestriction();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Config {
|
|
118
|
+
name: string;
|
|
119
|
+
intervalType: EventInterval;
|
|
120
|
+
intervalCount: number;
|
|
121
|
+
userIds: string[];
|
|
122
|
+
restrictionKind: RestrictionKind;
|
|
123
|
+
timezone: string | undefined;
|
|
124
|
+
start: Date;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rotationOf(c: Config): Recurring {
|
|
128
|
+
const rot: Recurring = new Recurring();
|
|
129
|
+
rot.intervalType = c.intervalType;
|
|
130
|
+
rot.intervalCount = new PositiveNumber(c.intervalCount);
|
|
131
|
+
return rot;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function layerOf(c: Config): LayerProps {
|
|
135
|
+
const rot: Recurring = rotationOf(c);
|
|
136
|
+
const handoff: Date = Recurring.getNextDateInterval(c.start, rot);
|
|
137
|
+
return {
|
|
138
|
+
users: c.userIds.map(user),
|
|
139
|
+
startDateTimeOfLayer: c.start,
|
|
140
|
+
restrictionTimes: buildRestriction(c.restrictionKind),
|
|
141
|
+
handOffTime: handoff,
|
|
142
|
+
rotation: rot,
|
|
143
|
+
timezone: c.timezone,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- horizons / windows -----------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* Ground-truth full-expansion horizon (days) — long enough to expose several
|
|
151
|
+
* rotation periods AND several coverage cycles (incl. a weekend) for sampling,
|
|
152
|
+
* while keeping the number of generated events modest.
|
|
153
|
+
*/
|
|
154
|
+
const GROUND_HORIZON_DAYS: Record<string, number> = {
|
|
155
|
+
[EventInterval.Hour]: 7,
|
|
156
|
+
[EventInterval.Day]: 24,
|
|
157
|
+
[EventInterval.Week]: 63,
|
|
158
|
+
[EventInterval.Month]: 135,
|
|
159
|
+
[EventInterval.Year]: 1100,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/*
|
|
163
|
+
* Windowed getEvents end (days): huge, but getNumberOfEvents forces an early
|
|
164
|
+
* return after 1-2 events, so this is O(a couple of periods), not the window.
|
|
165
|
+
*/
|
|
166
|
+
const WINDOWED_END_DAYS: number = 400;
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
* getMultiLayerEvents window (days): expands fully (no per-layer cap), so keep it
|
|
170
|
+
* small. The FIRST covered/next event is always within ~2 days (daily overnight
|
|
171
|
+
* gap <=16h, weekend gap <=2d, coverage otherwise immediate), so 5 days always
|
|
172
|
+
* contains it for every restriction in this matrix.
|
|
173
|
+
*/
|
|
174
|
+
const MULTI_WINDOW_DAYS: number = 5;
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
* Only sample substantial restriction gaps (daily overnight ~16h, weekend ~2d).
|
|
178
|
+
* This excludes the tiny +/-1s rotation-boundary stitch AND leaves room for the
|
|
179
|
+
* off-center nudge below to stay inside the gap.
|
|
180
|
+
*/
|
|
181
|
+
const GAP_THRESHOLD_MS: number = 2 * 60 * 60 * 1000;
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* Push a gap sample OFF the gap's geometric centre and off any whole-hour
|
|
185
|
+
* instant. A symmetric weekend gap's exact midpoint frequently lands on a clean
|
|
186
|
+
* rotation boundary (e.g. midnight for a day rotation), where the +/-1s stitch
|
|
187
|
+
* makes "next" ambiguous — the boundary artifact the task says to avoid. A prime
|
|
188
|
+
* 41m37s offset lands the sample at HH:41:37, clear of the HH:00:00 wall-clock
|
|
189
|
+
* rotation boundaries this matrix produces.
|
|
190
|
+
*/
|
|
191
|
+
const GAP_NUDGE_MS: number = 41 * 60 * 1000 + 37 * 1000;
|
|
192
|
+
|
|
193
|
+
const MAX_COVERED_SAMPLES: number = 3;
|
|
194
|
+
const MAX_GAP_SAMPLES: number = 2;
|
|
195
|
+
|
|
196
|
+
// A Monday 00:00 UTC.
|
|
197
|
+
const FIXED_START_JAN: Date = OneUptimeDate.fromString(
|
|
198
|
+
"2025-01-06T00:00:00.000Z",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// ---- expansion helpers ------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function fullExpand(layer: LayerProps, from: Date, to: Date): CalendarEvent[] {
|
|
204
|
+
const util: LayerUtil = new LayerUtil();
|
|
205
|
+
return util.getEvents({
|
|
206
|
+
...layer,
|
|
207
|
+
calendarStartDate: from,
|
|
208
|
+
calendarEndDate: to,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function windowedTitles(layer: LayerProps, at: Date, n: number): string[] {
|
|
213
|
+
const util: LayerUtil = new LayerUtil();
|
|
214
|
+
const events: CalendarEvent[] = util.getEvents(
|
|
215
|
+
{
|
|
216
|
+
...layer,
|
|
217
|
+
calendarStartDate: at,
|
|
218
|
+
calendarEndDate: OneUptimeDate.addRemoveDays(at, WINDOWED_END_DAYS),
|
|
219
|
+
},
|
|
220
|
+
{ getNumberOfEvents: n },
|
|
221
|
+
);
|
|
222
|
+
return events.map((e: CalendarEvent) => {
|
|
223
|
+
return e.title;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function multiLayerFirstTitle(layer: LayerProps, at: Date): string | null {
|
|
228
|
+
const util: LayerUtil = new LayerUtil();
|
|
229
|
+
const events: CalendarEvent[] = util.getMultiLayerEvents(
|
|
230
|
+
{
|
|
231
|
+
layers: [layer],
|
|
232
|
+
calendarStartDate: at,
|
|
233
|
+
calendarEndDate: OneUptimeDate.addRemoveDays(at, MULTI_WINDOW_DAYS),
|
|
234
|
+
},
|
|
235
|
+
{ getNumberOfEvents: 1 },
|
|
236
|
+
);
|
|
237
|
+
return events[0]?.title ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/*
|
|
241
|
+
* Earliest-first full events whose end is strictly after `at`. For a covered
|
|
242
|
+
* instant this begins with the covering event; for a gap instant it begins with
|
|
243
|
+
* the next covered event.
|
|
244
|
+
*/
|
|
245
|
+
function relevantEventsAfter(full: CalendarEvent[], at: Date): CalendarEvent[] {
|
|
246
|
+
const t: number = at.getTime();
|
|
247
|
+
return full
|
|
248
|
+
.filter((e: CalendarEvent) => {
|
|
249
|
+
return e.end.getTime() > t;
|
|
250
|
+
})
|
|
251
|
+
.sort((a: CalendarEvent, b: CalendarEvent) => {
|
|
252
|
+
return a.start.getTime() - b.start.getTime();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function durationMs(e: CalendarEvent): number {
|
|
257
|
+
return e.end.getTime() - e.start.getTime();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/*
|
|
261
|
+
* Median positive event duration — the yardstick for "significant" coverage.
|
|
262
|
+
* Rotation boundaries that fall a DST-hour inside a restriction-window edge leave
|
|
263
|
+
* a sub-window "sliver" of the previous user hugging the boundary (a variant of
|
|
264
|
+
* the +/-1s rotation-boundary stitch). The current-on-call resolution is correct
|
|
265
|
+
* inside such slivers, but the SECOND-in-line ("next after next") can stitch to
|
|
266
|
+
* the boundary artifact. We therefore only compare the 2nd windowed event when
|
|
267
|
+
* both oracle events involved are significant (>= a fraction of the median), i.e.
|
|
268
|
+
* not boundary slivers — matching the task's guidance to avoid boundary instants.
|
|
269
|
+
*/
|
|
270
|
+
function medianDurationMs(events: CalendarEvent[]): number {
|
|
271
|
+
const ds: number[] = events
|
|
272
|
+
.map((e: CalendarEvent) => {
|
|
273
|
+
return durationMs(e);
|
|
274
|
+
})
|
|
275
|
+
.filter((d: number) => {
|
|
276
|
+
return d > 0;
|
|
277
|
+
})
|
|
278
|
+
.sort((a: number, b: number) => {
|
|
279
|
+
return a - b;
|
|
280
|
+
});
|
|
281
|
+
if (ds.length === 0) {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const mid: number = Math.floor(ds.length / 2);
|
|
285
|
+
if (ds.length % 2 === 1) {
|
|
286
|
+
return ds[mid] as number;
|
|
287
|
+
}
|
|
288
|
+
return Math.floor(((ds[mid - 1] as number) + (ds[mid] as number)) / 2);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Spread up to `maxPicks` distinct indices across [0, n).
|
|
292
|
+
function pickSpread(n: number, maxPicks: number): number[] {
|
|
293
|
+
if (n <= 0) {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
if (n <= maxPicks) {
|
|
297
|
+
return Array.from({ length: n }, (_: unknown, i: number) => {
|
|
298
|
+
return i;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const picks: Set<number> = new Set<number>();
|
|
302
|
+
for (let j: number = 0; j < maxPicks; j++) {
|
|
303
|
+
const idx: number = Math.floor(((j + 0.5) / maxPicks) * n);
|
|
304
|
+
picks.add(Math.min(n - 1, idx));
|
|
305
|
+
}
|
|
306
|
+
return Array.from(picks).sort((a: number, b: number) => {
|
|
307
|
+
return a - b;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface Sample {
|
|
312
|
+
at: Date;
|
|
313
|
+
kind: "covered" | "gap";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/*
|
|
317
|
+
* Build off-boundary sample instants from a full expansion: event midpoints
|
|
318
|
+
* (covered) and large-gap midpoints (gap).
|
|
319
|
+
*/
|
|
320
|
+
function buildSamples(full: CalendarEvent[]): Sample[] {
|
|
321
|
+
const samples: Sample[] = [];
|
|
322
|
+
|
|
323
|
+
// Covered samples: interior midpoints of a spread of events.
|
|
324
|
+
const coveredIdx: number[] = pickSpread(full.length, MAX_COVERED_SAMPLES);
|
|
325
|
+
for (const i of coveredIdx) {
|
|
326
|
+
const e: CalendarEvent | undefined = full[i];
|
|
327
|
+
if (!e) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const s: number = e.start.getTime();
|
|
331
|
+
const en: number = e.end.getTime();
|
|
332
|
+
if (en - s < 2000) {
|
|
333
|
+
continue; // too short to have a safely-interior midpoint
|
|
334
|
+
}
|
|
335
|
+
samples.push({ at: new Date(Math.floor((s + en) / 2)), kind: "covered" });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Gap samples: off-centre instants inside substantial gaps between events.
|
|
339
|
+
const gapMidpoints: Date[] = [];
|
|
340
|
+
for (let i: number = 0; i < full.length - 1; i++) {
|
|
341
|
+
const cur: CalendarEvent | undefined = full[i];
|
|
342
|
+
const next: CalendarEvent | undefined = full[i + 1];
|
|
343
|
+
if (!cur || !next) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const gapStart: number = cur.end.getTime();
|
|
347
|
+
const gapEnd: number = next.start.getTime();
|
|
348
|
+
if (gapEnd - gapStart > GAP_THRESHOLD_MS) {
|
|
349
|
+
const centre: number = Math.floor((gapStart + gapEnd) / 2);
|
|
350
|
+
let at: number = centre + GAP_NUDGE_MS;
|
|
351
|
+
if (at >= gapEnd) {
|
|
352
|
+
at = centre - GAP_NUDGE_MS; // stay strictly inside (gap always >= 2h)
|
|
353
|
+
}
|
|
354
|
+
gapMidpoints.push(new Date(at));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const gapIdx: number[] = pickSpread(gapMidpoints.length, MAX_GAP_SAMPLES);
|
|
358
|
+
for (const i of gapIdx) {
|
|
359
|
+
const at: Date | undefined = gapMidpoints[i];
|
|
360
|
+
if (at) {
|
|
361
|
+
samples.push({ at, kind: "gap" });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return samples;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/*
|
|
369
|
+
* Core differential check for one config; returns a list of human-readable
|
|
370
|
+
* mismatch descriptions (empty => all windowed paths agree with the oracle).
|
|
371
|
+
*/
|
|
372
|
+
function checkConfig(c: Config): string[] {
|
|
373
|
+
const layer: LayerProps = layerOf(c);
|
|
374
|
+
const groundToDays: number = GROUND_HORIZON_DAYS[c.intervalType] ?? 60;
|
|
375
|
+
const full: CalendarEvent[] = fullExpand(
|
|
376
|
+
layer,
|
|
377
|
+
c.start,
|
|
378
|
+
OneUptimeDate.addRemoveDays(c.start, groundToDays),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const mismatches: string[] = [];
|
|
382
|
+
|
|
383
|
+
if (full.length === 0) {
|
|
384
|
+
mismatches.push("full expansion produced ZERO events (unexpected)");
|
|
385
|
+
return mismatches;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const sigMs: number = 0.35 * medianDurationMs(full);
|
|
389
|
+
const samples: Sample[] = buildSamples(full);
|
|
390
|
+
|
|
391
|
+
for (const sample of samples) {
|
|
392
|
+
const at: Date = sample.at;
|
|
393
|
+
const relevant: CalendarEvent[] = relevantEventsAfter(full, at);
|
|
394
|
+
|
|
395
|
+
if (relevant.length === 0) {
|
|
396
|
+
// Interior samples always have a relevant full event; guard anyway.
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const expected0: string = relevant[0]!.title;
|
|
401
|
+
const iso: string = at.toISOString();
|
|
402
|
+
|
|
403
|
+
// (1) windowed getNumberOfEvents:1 — the "who is on call now / next" primitive.
|
|
404
|
+
const w1: string[] = windowedTitles(layer, at, 1);
|
|
405
|
+
if (w1.length === 0) {
|
|
406
|
+
mismatches.push(
|
|
407
|
+
`${sample.kind} at=${iso} windowed(1) returned NOTHING; expected=${expected0}`,
|
|
408
|
+
);
|
|
409
|
+
} else if (w1[0] !== expected0) {
|
|
410
|
+
mismatches.push(
|
|
411
|
+
`${sample.kind} at=${iso} windowed(1)[0]=${w1[0]} expected=${expected0}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// (2) windowed getNumberOfEvents:2 — first two on the roster.
|
|
416
|
+
const w2: string[] = windowedTitles(layer, at, 2);
|
|
417
|
+
if (w2.length === 0) {
|
|
418
|
+
mismatches.push(
|
|
419
|
+
`${sample.kind} at=${iso} windowed(2) returned NOTHING; expected=${expected0}`,
|
|
420
|
+
);
|
|
421
|
+
} else if (w2[0] !== expected0) {
|
|
422
|
+
mismatches.push(
|
|
423
|
+
`${sample.kind} at=${iso} windowed(2)[0]=${w2[0]} expected=${expected0}`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/*
|
|
428
|
+
* (2b) Second-in-line — only where both oracle events are significant (not
|
|
429
|
+
* boundary slivers), so the comparison is off-boundary as the task requires.
|
|
430
|
+
*/
|
|
431
|
+
const canCheckNext: boolean =
|
|
432
|
+
relevant.length >= 2 &&
|
|
433
|
+
durationMs(relevant[0]!) >= sigMs &&
|
|
434
|
+
durationMs(relevant[1]!) >= sigMs;
|
|
435
|
+
if (canCheckNext) {
|
|
436
|
+
const expected1: string = relevant[1]!.title;
|
|
437
|
+
if (w2.length < 2) {
|
|
438
|
+
mismatches.push(
|
|
439
|
+
`${sample.kind} at=${iso} windowed(2) had <2 events but oracle 2nd=${expected1}`,
|
|
440
|
+
);
|
|
441
|
+
} else if (w2[1] !== expected1) {
|
|
442
|
+
mismatches.push(
|
|
443
|
+
`${sample.kind} at=${iso} windowed(2)[1]=${w2[1]} expected=${expected1}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// (3) getMultiLayerEvents — the actual live roster path.
|
|
449
|
+
const m1: string | null = multiLayerFirstTitle(layer, at);
|
|
450
|
+
if (m1 === null) {
|
|
451
|
+
mismatches.push(
|
|
452
|
+
`${sample.kind} at=${iso} multiLayer returned NOTHING; expected=${expected0}`,
|
|
453
|
+
);
|
|
454
|
+
} else if (m1 !== expected0) {
|
|
455
|
+
mismatches.push(
|
|
456
|
+
`${sample.kind} at=${iso} multiLayer[0]=${m1} expected=${expected0}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return mismatches;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---- matrix -----------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
const INTERVALS: EventInterval[] = [
|
|
467
|
+
EventInterval.Hour,
|
|
468
|
+
EventInterval.Day,
|
|
469
|
+
EventInterval.Week,
|
|
470
|
+
EventInterval.Month,
|
|
471
|
+
];
|
|
472
|
+
const COUNTS: number[] = [1, 2, 3];
|
|
473
|
+
// Multi-user rotation sets (single-user is covered separately below).
|
|
474
|
+
const USER_SETS: string[][] = [
|
|
475
|
+
["A", "B"],
|
|
476
|
+
["A", "B", "C"],
|
|
477
|
+
];
|
|
478
|
+
const RESTRICTIONS: RestrictionKind[] = ["none", "daily09-17", "weeklyMonFri"];
|
|
479
|
+
const TIMEZONES: (string | undefined)[] = [
|
|
480
|
+
undefined,
|
|
481
|
+
"America/New_York",
|
|
482
|
+
"Asia/Kolkata",
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
function buildMatrix(): Config[] {
|
|
486
|
+
const configs: Config[] = [];
|
|
487
|
+
for (const it of INTERVALS) {
|
|
488
|
+
for (const count of COUNTS) {
|
|
489
|
+
for (const users of USER_SETS) {
|
|
490
|
+
for (const kind of RESTRICTIONS) {
|
|
491
|
+
for (const tz of TIMEZONES) {
|
|
492
|
+
configs.push({
|
|
493
|
+
name: `${count}x${it} users=${users.length} ${kind} tz=${tz ?? "local"}`,
|
|
494
|
+
intervalType: it,
|
|
495
|
+
intervalCount: count,
|
|
496
|
+
userIds: users,
|
|
497
|
+
restrictionKind: kind,
|
|
498
|
+
timezone: tz,
|
|
499
|
+
start: FIXED_START_JAN,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return configs;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/*
|
|
510
|
+
* REGRESSION: intervalCount >= 2 rotation-boundary overshoot.
|
|
511
|
+
*
|
|
512
|
+
* moveHandsOffTimeAfterCurrentEventStartTime used to align the next handoff via
|
|
513
|
+
* ceil(getUnitsInclusive / interval) * interval, which OVERSHOT by a full
|
|
514
|
+
* rotation period for query instants sitting in the last partial period before a
|
|
515
|
+
* boundary — and a DST offset shift could push the inclusive unit count across
|
|
516
|
+
* an even/odd threshold, triggering it. The first windowed period then spanned
|
|
517
|
+
* TWO rotations and resolved the wrong current/next on-call user.
|
|
518
|
+
*
|
|
519
|
+
* Config that reproduced it: an every-2-days rotation [A,B,C] with a weekly
|
|
520
|
+
* restriction, America/New_York, sampled in the ~1h before a Saturday-evening
|
|
521
|
+
* 2-day boundary during the week after US fall-back. The calendar's next covered
|
|
522
|
+
* shift is B; the windowed resolution used to return A.
|
|
523
|
+
*/
|
|
524
|
+
describe("REGRESSION: intervalCount>=2 boundary overshoot resolves the correct next user", () => {
|
|
525
|
+
it("every-2-days + weekly restriction, gap query just before a post-DST boundary", () => {
|
|
526
|
+
const c: Config = {
|
|
527
|
+
name: "reg-2xday-weekly",
|
|
528
|
+
intervalType: EventInterval.Day,
|
|
529
|
+
intervalCount: 2,
|
|
530
|
+
userIds: ["A", "B", "C"],
|
|
531
|
+
restrictionKind: "weeklyMonFri",
|
|
532
|
+
timezone: "America/New_York",
|
|
533
|
+
start: OneUptimeDate.fromString("2025-10-27T00:00:00.000Z"),
|
|
534
|
+
};
|
|
535
|
+
const layer: LayerProps = layerOf(c);
|
|
536
|
+
const util: LayerUtil = new LayerUtil();
|
|
537
|
+
const full: CalendarEvent[] = fullExpand(
|
|
538
|
+
layer,
|
|
539
|
+
c.start,
|
|
540
|
+
OneUptimeDate.fromString("2025-11-25T00:00:00.000Z"),
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
/*
|
|
544
|
+
* Probe every 20 minutes across the Nov 14-18 weekend; the windowed "next
|
|
545
|
+
* covered user" must always equal the full-expansion oracle (cover, else the
|
|
546
|
+
* next event after the instant). Before the fix, Sat 19:00-19:40 EST
|
|
547
|
+
* diverged (windowed A vs oracle B).
|
|
548
|
+
*/
|
|
549
|
+
const startProbe: Date = OneUptimeDate.fromString(
|
|
550
|
+
"2025-11-14T18:00:00.000Z",
|
|
551
|
+
);
|
|
552
|
+
const mismatches: string[] = [];
|
|
553
|
+
for (let i: number = 0; i < 72 * 3; i++) {
|
|
554
|
+
const at: Date = OneUptimeDate.addRemoveMinutes(startProbe, i * 20);
|
|
555
|
+
let cover: CalendarEvent | null = null;
|
|
556
|
+
let next: CalendarEvent | null = null;
|
|
557
|
+
for (const e of full) {
|
|
558
|
+
if (
|
|
559
|
+
OneUptimeDate.isOnOrAfter(at, e.start) &&
|
|
560
|
+
OneUptimeDate.isBefore(at, e.end)
|
|
561
|
+
) {
|
|
562
|
+
cover = e;
|
|
563
|
+
}
|
|
564
|
+
if (OneUptimeDate.isAfter(e.start, at)) {
|
|
565
|
+
if (!next || OneUptimeDate.isBefore(e.start, next.start)) {
|
|
566
|
+
next = e;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const oracle: string | null = cover
|
|
571
|
+
? cover.title
|
|
572
|
+
: next
|
|
573
|
+
? next.title
|
|
574
|
+
: null;
|
|
575
|
+
const windowed: string | null =
|
|
576
|
+
util.getEvents(
|
|
577
|
+
{
|
|
578
|
+
...layer,
|
|
579
|
+
calendarStartDate: at,
|
|
580
|
+
calendarEndDate: OneUptimeDate.addRemoveDays(at, 20),
|
|
581
|
+
},
|
|
582
|
+
{ getNumberOfEvents: 1 },
|
|
583
|
+
)[0]?.title ?? null;
|
|
584
|
+
if (windowed !== oracle) {
|
|
585
|
+
mismatches.push(
|
|
586
|
+
`${at.toISOString()} windowed=${windowed} oracle=${oracle}`,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
expect(mismatches).toEqual([]);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("does not overshoot the boundary for a range of every-N-day/hour rotations", () => {
|
|
594
|
+
/*
|
|
595
|
+
* Broad guard: for several interval counts, resolving anywhere inside a
|
|
596
|
+
* period yields a first event whose END is the NEXT on-grid boundary (never
|
|
597
|
+
* one period further out).
|
|
598
|
+
*/
|
|
599
|
+
const util: LayerUtil = new LayerUtil();
|
|
600
|
+
const start: Date = OneUptimeDate.fromString("2025-01-06T00:00:00.000Z");
|
|
601
|
+
for (const [it, count] of [
|
|
602
|
+
[EventInterval.Day, 2],
|
|
603
|
+
[EventInterval.Day, 3],
|
|
604
|
+
[EventInterval.Hour, 2],
|
|
605
|
+
[EventInterval.Hour, 3],
|
|
606
|
+
[EventInterval.Week, 2],
|
|
607
|
+
] as Array<[EventInterval, number]>) {
|
|
608
|
+
const layer: LayerProps = layerOf({
|
|
609
|
+
name: `guard-${count}x${it}`,
|
|
610
|
+
intervalType: it,
|
|
611
|
+
intervalCount: count,
|
|
612
|
+
userIds: ["A", "B", "C"],
|
|
613
|
+
restrictionKind: "none",
|
|
614
|
+
timezone: "America/New_York",
|
|
615
|
+
start,
|
|
616
|
+
});
|
|
617
|
+
const full: CalendarEvent[] = fullExpand(
|
|
618
|
+
layer,
|
|
619
|
+
start,
|
|
620
|
+
OneUptimeDate.addRemoveDays(start, 40),
|
|
621
|
+
);
|
|
622
|
+
/*
|
|
623
|
+
* For each full event, sampling 3 points inside it must resolve the same
|
|
624
|
+
* user as the full expansion (current user correct, no overshoot).
|
|
625
|
+
*/
|
|
626
|
+
for (const e of full.slice(1, 12)) {
|
|
627
|
+
const mid: Date = new Date((e.start.getTime() + e.end.getTime()) / 2);
|
|
628
|
+
const windowed: string | null =
|
|
629
|
+
util.getEvents(
|
|
630
|
+
{
|
|
631
|
+
...layer,
|
|
632
|
+
calendarStartDate: mid,
|
|
633
|
+
calendarEndDate: OneUptimeDate.addRemoveDays(mid, 40),
|
|
634
|
+
},
|
|
635
|
+
{ getNumberOfEvents: 1 },
|
|
636
|
+
)[0]?.title ?? null;
|
|
637
|
+
expect(windowed).toBe(e.title);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe("Windowed current/next resolution == full expansion (broad matrix)", () => {
|
|
644
|
+
const configs: Config[] = buildMatrix();
|
|
645
|
+
|
|
646
|
+
for (const c of configs) {
|
|
647
|
+
it(`${c.name}`, () => {
|
|
648
|
+
const mismatches: string[] = checkConfig(c);
|
|
649
|
+
expect({ config: c.name, mismatches }).toEqual({
|
|
650
|
+
config: c.name,
|
|
651
|
+
mismatches: [],
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
/*
|
|
658
|
+
* Single-user layers: no rotation ever occurs (always the same person), across
|
|
659
|
+
* every interval x restriction x tz. The windowed/live paths must still agree
|
|
660
|
+
* with the full expansion (coverage windows, gaps, and boundaries all resolve to
|
|
661
|
+
* the one user).
|
|
662
|
+
*/
|
|
663
|
+
describe("Single-user layers: windowed == full (every interval/restriction/tz)", () => {
|
|
664
|
+
const configs: Config[] = [];
|
|
665
|
+
for (const it of INTERVALS) {
|
|
666
|
+
for (const kind of RESTRICTIONS) {
|
|
667
|
+
for (const tz of TIMEZONES) {
|
|
668
|
+
configs.push({
|
|
669
|
+
name: `1x${it} users=1 ${kind} tz=${tz ?? "local"}`,
|
|
670
|
+
intervalType: it,
|
|
671
|
+
intervalCount: 1,
|
|
672
|
+
userIds: ["A"],
|
|
673
|
+
restrictionKind: kind,
|
|
674
|
+
timezone: tz,
|
|
675
|
+
start: FIXED_START_JAN,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
for (const c of configs) {
|
|
682
|
+
it(`${c.name}`, () => {
|
|
683
|
+
const mismatches: string[] = checkConfig(c);
|
|
684
|
+
expect({ config: c.name, mismatches }).toEqual({
|
|
685
|
+
config: c.name,
|
|
686
|
+
mismatches: [],
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
/*
|
|
693
|
+
* DST-spanning starts stress server-vs-schedule wall-clock divergence. New York
|
|
694
|
+
* springs forward 2025-03-09 and falls back 2025-11-02; Kolkata never shifts.
|
|
695
|
+
* These are especially meaningful under TZ=UTC (server zone != schedule zone).
|
|
696
|
+
*/
|
|
697
|
+
function buildDstMatrix(): Config[] {
|
|
698
|
+
const dstStarts: { label: string; start: Date }[] = [
|
|
699
|
+
{
|
|
700
|
+
label: "spring-forward-week",
|
|
701
|
+
start: OneUptimeDate.fromString("2025-03-03T00:00:00.000Z"), // Monday before US DST
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
label: "fall-back-week",
|
|
705
|
+
start: OneUptimeDate.fromString("2025-10-27T00:00:00.000Z"), // Monday before US fall-back
|
|
706
|
+
},
|
|
707
|
+
];
|
|
708
|
+
const dstIntervals: EventInterval[] = [EventInterval.Day, EventInterval.Week];
|
|
709
|
+
const dstRestrictions: RestrictionKind[] = ["daily09-17", "weeklyMonFri"];
|
|
710
|
+
const dstTimezones: (string | undefined)[] = [
|
|
711
|
+
"America/New_York",
|
|
712
|
+
"Asia/Kolkata",
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
const configs: Config[] = [];
|
|
716
|
+
for (const s of dstStarts) {
|
|
717
|
+
for (const intervalType of dstIntervals) {
|
|
718
|
+
for (const count of [1, 2]) {
|
|
719
|
+
for (const kind of dstRestrictions) {
|
|
720
|
+
for (const tz of dstTimezones) {
|
|
721
|
+
configs.push({
|
|
722
|
+
name: `${s.label} ${count}x${intervalType} ${kind} tz=${tz ?? "local"}`,
|
|
723
|
+
intervalType,
|
|
724
|
+
intervalCount: count,
|
|
725
|
+
userIds: ["A", "B", "C"],
|
|
726
|
+
restrictionKind: kind,
|
|
727
|
+
timezone: tz,
|
|
728
|
+
start: s.start,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return configs;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
describe("Windowed == full across DST transitions (schedule tz vs server)", () => {
|
|
739
|
+
const configs: Config[] = buildDstMatrix();
|
|
740
|
+
|
|
741
|
+
for (const c of configs) {
|
|
742
|
+
it(`${c.name}`, () => {
|
|
743
|
+
const mismatches: string[] = checkConfig(c);
|
|
744
|
+
expect({ config: c.name, mismatches }).toEqual({
|
|
745
|
+
config: c.name,
|
|
746
|
+
mismatches: [],
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
/*
|
|
753
|
+
* A handful of CONCRETE anchors (absolute expected users) so the suite pins real
|
|
754
|
+
* behavior, not merely windowed==full self-consistency. These are independently
|
|
755
|
+
* hand-derivable from the rotation/restriction definition.
|
|
756
|
+
*/
|
|
757
|
+
describe("Concrete anchors (absolute expected on-call user)", () => {
|
|
758
|
+
const MON_JAN6: Date = FIXED_START_JAN;
|
|
759
|
+
|
|
760
|
+
function dailyRotationLayer(
|
|
761
|
+
users: string[],
|
|
762
|
+
intervalCount: number,
|
|
763
|
+
restrictionKind: RestrictionKind,
|
|
764
|
+
): LayerProps {
|
|
765
|
+
return layerOf({
|
|
766
|
+
name: "anchor",
|
|
767
|
+
intervalType: EventInterval.Day,
|
|
768
|
+
intervalCount,
|
|
769
|
+
userIds: users,
|
|
770
|
+
restrictionKind,
|
|
771
|
+
timezone: undefined,
|
|
772
|
+
start: MON_JAN6,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function firstTitleAt(layer: LayerProps, at: Date): string | null {
|
|
777
|
+
return windowedTitles(layer, at, 1)[0] ?? null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
test("unrestricted x1 daily [A,B] rotates A,B,A,B on successive days (noon samples)", () => {
|
|
781
|
+
const layer: LayerProps = dailyRotationLayer(["A", "B"], 1, "none");
|
|
782
|
+
const expectedByDay: string[] = ["A", "B", "A", "B", "A", "B"];
|
|
783
|
+
for (let day: number = 0; day < expectedByDay.length; day++) {
|
|
784
|
+
const noon: Date = OneUptimeDate.addRemoveHours(
|
|
785
|
+
OneUptimeDate.addRemoveDays(MON_JAN6, day),
|
|
786
|
+
12,
|
|
787
|
+
);
|
|
788
|
+
expect(firstTitleAt(layer, noon)).toBe(expectedByDay[day]);
|
|
789
|
+
expect(multiLayerFirstTitle(layer, noon)).toBe(expectedByDay[day]);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("unrestricted x2 daily [A,B,C] holds each user for two days then advances", () => {
|
|
794
|
+
// Periods: [Jan6,Jan8)=A, [Jan8,Jan10)=B, [Jan10,Jan12)=C, [Jan12,Jan14)=A ...
|
|
795
|
+
const layer: LayerProps = dailyRotationLayer(["A", "B", "C"], 2, "none");
|
|
796
|
+
const cases: { day: number; who: string }[] = [
|
|
797
|
+
{ day: 0, who: "A" },
|
|
798
|
+
{ day: 1, who: "A" },
|
|
799
|
+
{ day: 2, who: "B" },
|
|
800
|
+
{ day: 3, who: "B" },
|
|
801
|
+
{ day: 4, who: "C" },
|
|
802
|
+
{ day: 5, who: "C" },
|
|
803
|
+
{ day: 6, who: "A" },
|
|
804
|
+
{ day: 7, who: "A" },
|
|
805
|
+
];
|
|
806
|
+
for (const cse of cases) {
|
|
807
|
+
const noon: Date = OneUptimeDate.addRemoveHours(
|
|
808
|
+
OneUptimeDate.addRemoveDays(MON_JAN6, cse.day),
|
|
809
|
+
12,
|
|
810
|
+
);
|
|
811
|
+
expect(firstTitleAt(layer, noon)).toBe(cse.who);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("daily 09-17 x1 [A,B]: an evening gap resolves the NEXT day's user (F2)", () => {
|
|
816
|
+
const layer: LayerProps = dailyRotationLayer(["A", "B"], 1, "daily09-17");
|
|
817
|
+
// Jan6=A(9-17), Jan7=B, Jan8=A ... Evening (20:00) gap => next day's user.
|
|
818
|
+
const nextByEveningDay: string[] = ["B", "A", "B", "A", "B"];
|
|
819
|
+
for (let day: number = 0; day < nextByEveningDay.length; day++) {
|
|
820
|
+
const evening: Date = OneUptimeDate.addRemoveHours(
|
|
821
|
+
OneUptimeDate.addRemoveDays(MON_JAN6, day),
|
|
822
|
+
20,
|
|
823
|
+
);
|
|
824
|
+
expect(firstTitleAt(layer, evening)).toBe(nextByEveningDay[day]);
|
|
825
|
+
expect(multiLayerFirstTitle(layer, evening)).toBe(nextByEveningDay[day]);
|
|
826
|
+
}
|
|
827
|
+
// And inside a covered window the CURRENT user is that day's user.
|
|
828
|
+
expect(
|
|
829
|
+
firstTitleAt(
|
|
830
|
+
layer,
|
|
831
|
+
OneUptimeDate.addRemoveHours(
|
|
832
|
+
OneUptimeDate.addRemoveDays(MON_JAN6, 1),
|
|
833
|
+
12,
|
|
834
|
+
),
|
|
835
|
+
),
|
|
836
|
+
).toBe("B");
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test("weekly Mon-Fri x1 [A,B]: weekend gap resolves next Monday's rotated user", () => {
|
|
840
|
+
const layer: LayerProps = layerOf({
|
|
841
|
+
name: "anchor-weekly",
|
|
842
|
+
intervalType: EventInterval.Week,
|
|
843
|
+
intervalCount: 1,
|
|
844
|
+
userIds: ["A", "B"],
|
|
845
|
+
restrictionKind: "weeklyMonFri",
|
|
846
|
+
timezone: undefined,
|
|
847
|
+
start: MON_JAN6,
|
|
848
|
+
});
|
|
849
|
+
/*
|
|
850
|
+
* Week rotation: week1 (Jan6-)=A, week2 (Jan13-)=B, week3=A ...
|
|
851
|
+
* Saturday of week1 (Jan11 12:00) is a weekend gap => next Monday Jan13 = B.
|
|
852
|
+
*/
|
|
853
|
+
const week1Saturday: Date = OneUptimeDate.fromString(
|
|
854
|
+
"2025-01-11T12:00:00.000Z",
|
|
855
|
+
);
|
|
856
|
+
expect(firstTitleAt(layer, week1Saturday)).toBe("B");
|
|
857
|
+
expect(multiLayerFirstTitle(layer, week1Saturday)).toBe("B");
|
|
858
|
+
|
|
859
|
+
// Mid-week1 (Wed Jan8 12:00) is covered => current user A.
|
|
860
|
+
const week1Wed: Date = OneUptimeDate.fromString("2025-01-08T12:00:00.000Z");
|
|
861
|
+
expect(firstTitleAt(layer, week1Wed)).toBe("A");
|
|
862
|
+
});
|
|
863
|
+
});
|