@salesforce/afv-skills 1.63.0 → 1.64.0
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/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +253 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +796 -0
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +111 -0
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +1053 -0
- package/skills/automation-sandbox-post-copy-config-generate/SKILL.md +21 -11
- package/skills/automation-sandbox-post-copy-config-generate/assets/config_template.json +10 -0
- package/skills/automation-sandbox-post-copy-config-generate/assets/json_schema.json +33 -1
- package/skills/automation-sandbox-post-copy-config-generate/references/configuration_catalog.md +44 -1
- package/skills/automation-sandbox-post-copy-configure/SKILL.md +132 -124
- package/skills/automation-sandbox-post-copy-configure/references/flow_activation_path.md +151 -0
- package/skills/automation-sandbox-post-copy-configure/references/scheduled_apex_path.md +55 -9
- package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +54 -107
- package/skills/consumer-goods-promotion-bo-api-deploy/assets/set-comment-value/payloads/copy.json +1 -1
- package/skills/consumer-goods-promotion-bo-api-deploy/references/conventions-and-payload-rules.md +82 -5
- package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +35 -2
- package/skills/consumer-goods-promotion-bo-api-deploy/references/preflight-and-seed-queries.md +149 -0
- package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +17 -1
- package/skills/dx-devops-conflict-resolve/SKILL.md +1 -5
- package/skills/dx-devops-pipeline-manage/SKILL.md +1 -5
- package/skills/dx-devops-project-manage/SKILL.md +1 -5
- package/skills/dx-devops-promote/SKILL.md +1 -5
- package/skills/dx-devops-request-status/SKILL.md +1 -5
- package/skills/dx-lifecycle-center/SKILL.md +120 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +175 -0
- package/skills/dx-lifecycle-center/references/connect-api.md +123 -0
- package/skills/dx-lifecycle-center/references/connect-org.md +184 -0
- package/skills/dx-lifecycle-center/references/deploy.md +153 -0
- package/skills/dx-lifecycle-center/references/error-handling.md +21 -0
- package/skills/dx-lifecycle-center/references/hub-org.md +37 -0
- package/skills/dx-lifecycle-center/references/list-orgs.md +99 -0
- package/skills/dx-lifecycle-center/references/request-body.md +75 -0
- package/skills/dx-lifecycle-center/references/troubleshooting.md +31 -0
- package/skills/dx-lifecycle-center/scripts/poll-deploy-status.sh +200 -0
- package/skills/dx-lifecycle-center/scripts/resolve-environment.sh +120 -0
- package/skills/dx-org-devhub-configure/SKILL.md +1 -3
- package/skills/dx-org-shape-manage/SKILL.md +1 -5
- package/skills/dx-org-snapshot-manage/SKILL.md +1 -3
- package/skills/education-cloud-academic-calendar-generate/SKILL.md +1 -3
- package/skills/education-cloud-course-catalog-migrate/SKILL.md +1 -3
- package/skills/education-cloud-domain-configure/SKILL.md +1 -3
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -3
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -5
- package/skills/experience-ui-bundle-2gp-deploy/SKILL.md +1 -3
- package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -5
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -3
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -3
- package/skills/insurance-brokerage-agency-billing-configure/SKILL.md +1 -3
- package/skills/marketing-message-test/SKILL.md +1 -3
- package/skills/nonprofit-fundraising-configure/SKILL.md +240 -0
- package/skills/nonprofit-fundraising-configure/references/gotchas.md +26 -0
- package/skills/nonprofit-fundraising-configure/references/mcp-invocation.md +678 -0
- package/skills/nonprofit-fundraising-configure/references/permission-targeting.md +35 -0
- package/skills/nonprofit-volunteer-management-model-get/SKILL.md +166 -0
- package/skills/nonprofit-volunteer-management-model-get/references/entities.md +315 -0
- package/skills/nonprofit-volunteer-management-model-get/references/relationships.md +175 -0
- package/skills/nonprofit-volunteer-management-model-get/references/themes.md +143 -0
- package/skills/nonprofit-volunteer-management-records-create/SKILL.md +121 -0
- package/skills/nonprofit-volunteer-management-records-create/references/field-guide.md +284 -0
- package/skills/nonprofit-volunteer-management-records-create/references/mcp-invocation.md +248 -0
- package/skills/nonprofit-volunteer-management-records-create/references/review-format.md +140 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/expand-series.py +632 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/render-review-table.py +419 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_expand_series.py +690 -0
- package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_render_review_table.py +421 -0
- package/skills/nonprofit-volunteer-management-structure-generate/SKILL.md +119 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/configuration-decisions.md +159 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/discovery-interview.md +137 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/edge-cases.md +48 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/mcp-invocation.md +75 -0
- package/skills/nonprofit-volunteer-management-structure-generate/references/plan-output.md +141 -0
- package/skills/platform-custom-field-generate/SKILL.md +1 -0
- package/skills/platform-custom-field-generate/references/advanced-picklists.md +5 -5
- package/skills/platform-custom-metadata-type-generate/SKILL.md +3 -0
- package/skills/platform-custom-object-generate/SKILL.md +4 -0
- package/skills/platform-custom-setting-generate/SKILL.md +3 -0
- package/skills/platform-datamask-run/SKILL.md +1 -5
- package/skills/platform-permission-set-generate/SKILL.md +3 -0
- package/skills/platform-salesforce-connect-adapter-generate/SKILL.md +1 -3
- package/skills/platform-sandbox-configure/SKILL.md +1 -3
- package/skills/platform-value-set-generate/SKILL.md +3 -0
- package/skills/sales-call-scoring-configure/SKILL.md +1 -5
- package/skills/service-agentforce-channel-configure/SKILL.md +1 -3
- package/skills/service-agentforce-human-escalation-configure/SKILL.md +1 -3
- package/skills/service-catalog-template-coordinate/SKILL.md +1 -3
- package/skills/service-concierge-portal-generate/SKILL.md +1 -3
- package/skills/service-email-to-case-configure/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-agent-runtime-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-agentforce-studio-validate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-access-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-bundle-deploy/SKILL.md +1 -5
- package/skills/service-itsm-agentic-setup-cmdb-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-coordinate/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-cmdb-discovery-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-fulfiller-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-incident-sla-configure/SKILL.md +1 -0
- package/skills/service-itsm-agentic-setup-itsm-agentforce-permset-assign/SKILL.md +1 -3
- package/skills/service-itsm-agentic-setup-uel-user-create/SKILL.md +1 -9
- package/skills/service-itsm-incident-mgmt-configure/SKILL.md +1 -5
- package/skills/service-itsm-incident-priority-configure/SKILL.md +1 -3
- package/skills/service-itsm-slack-itservice-configure/SKILL.md +1 -3
- package/skills/service-itsm-swarming-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-debug/SKILL.md +1 -3
- package/skills/service-itsm-teams-employee-agent-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itdesk-configure/SKILL.md +1 -3
- package/skills/service-itsm-teams-itservice-configure/SKILL.md +1 -3
- package/skills/service-native-voice-recording-transcription-configure/SKILL.md +1 -3
- package/skills/service-omni-agent-users-create/SKILL.md +1 -3
- package/skills/service-omni-agent-work-sharing-configure/SKILL.md +1 -3
- package/skills/service-omni-attribute-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-base-settings-configure/SKILL.md +1 -3
- package/skills/service-omni-channel-inventory-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-limits-analyze/SKILL.md +1 -3
- package/skills/service-omni-channel-setup-coordinate/SKILL.md +1 -3
- package/skills/service-omni-command-center-analyze/SKILL.md +1 -3
- package/skills/service-omni-command-center-configure/SKILL.md +1 -3
- package/skills/service-omni-permission-set-assign/SKILL.md +1 -3
- package/skills/service-omni-presence-status-deploy/SKILL.md +1 -3
- package/skills/service-omni-presence-user-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-deploy/SKILL.md +1 -3
- package/skills/service-omni-queue-members-assign/SKILL.md +1 -3
- package/skills/service-omni-queue-routing-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-routing-flow-deploy/SKILL.md +1 -3
- package/skills/service-omni-service-channel-configure/SKILL.md +1 -3
- package/skills/service-omni-sidebar-configure/SKILL.md +1 -3
- package/skills/service-omni-skills-based-routing-configure/SKILL.md +1 -3
- package/skills/service-omni-supervisor-config-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-permset-assign/SKILL.md +1 -3
- package/skills/service-omni-supervisor-surface-deploy/SKILL.md +1 -3
- package/skills/service-omni-supervisor-users-create/SKILL.md +1 -3
- package/skills/service-omni-work-skill-routing-configure/SKILL.md +1 -3
- package/skills/field-service-setup-orchestrator-get/SKILL.md +0 -47
- package/skills/tableau-next-asset-create/README.md +0 -65
package/skills/nonprofit-volunteer-management-records-create/scripts/tests/test_expand_series.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for expand-series.py.
|
|
3
|
+
|
|
4
|
+
Run manually (the repo has no Python test runner wired into CI today):
|
|
5
|
+
|
|
6
|
+
cd contributed-skills/npc-titan/nonprofit-volunteer-management-records-create/scripts/tests
|
|
7
|
+
python3 -m unittest test_expand_series -v
|
|
8
|
+
|
|
9
|
+
These lock down the behaviors that a silent regression would corrupt: per-day windows
|
|
10
|
+
(the cross-product bug), the uniform-window path, composite date sets (multiple
|
|
11
|
+
dateRanges + oneOffDates union), excludeDates in-memory removal, dedup via excludeExisting,
|
|
12
|
+
exact-duplicate collapse, the count bound, the >200 batching, and the spec-validation failures.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import unittest
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dt_date(iso):
|
|
26
|
+
return dt.date.fromisoformat(iso)
|
|
27
|
+
|
|
28
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
29
|
+
SCRIPT = os.path.join(os.path.dirname(HERE), "expand-series.py")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(spec, args=None):
|
|
33
|
+
"""Invoke the script with a spec on stdin; return (returncode, records_or_None, stderr)."""
|
|
34
|
+
proc = subprocess.run(
|
|
35
|
+
[sys.executable, SCRIPT] + (args or []),
|
|
36
|
+
input=json.dumps(spec),
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
)
|
|
40
|
+
records = None
|
|
41
|
+
out = proc.stdout.strip()
|
|
42
|
+
# stdout is a tree-import object by default, but a list of file paths under --out-dir.
|
|
43
|
+
if proc.returncode == 0 and out.startswith("{"):
|
|
44
|
+
records = json.loads(out)["records"]
|
|
45
|
+
return proc.returncode, records, proc.stderr
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def keys(records):
|
|
49
|
+
return [(r["StartDate"], r["EndDate"], r["StartTime"], r["EndTime"]) for r in records]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UniformWindows(unittest.TestCase):
|
|
53
|
+
def test_flat_windows_apply_to_every_day(self):
|
|
54
|
+
rc, recs, _ = run({
|
|
55
|
+
"entity": "JobPositionShift",
|
|
56
|
+
"parentId": "0PS000000000001",
|
|
57
|
+
"daysOfWeek": ["Tuesday", "Thursday"],
|
|
58
|
+
"windows": [{"start": "15:30", "end": "17:30"}],
|
|
59
|
+
"dateRange": {"start": "2026-09-01", "end": "2026-09-14"}, # 2 Tue + 2 Thu
|
|
60
|
+
"timeZone": "America/New_York",
|
|
61
|
+
})
|
|
62
|
+
self.assertEqual(rc, 0)
|
|
63
|
+
self.assertEqual(len(recs), 4)
|
|
64
|
+
for r in recs:
|
|
65
|
+
self.assertEqual(r["StartTime"], "15:30:00.000Z")
|
|
66
|
+
self.assertEqual(r["EndTime"], "17:30:00.000Z")
|
|
67
|
+
self.assertEqual(r["StartDate"], r["EndDate"])
|
|
68
|
+
self.assertEqual(r["TimeZone"], "America/New_York")
|
|
69
|
+
|
|
70
|
+
def test_capacity_and_status_defaults(self):
|
|
71
|
+
rc, recs, _ = run({
|
|
72
|
+
"entity": "JobPositionShift",
|
|
73
|
+
"parentRef": "JobPosition_1",
|
|
74
|
+
"oneOffDates": ["2026-09-06"],
|
|
75
|
+
"windows": [{"start": "12:00", "end": "16:00"}],
|
|
76
|
+
"timeZone": "America/New_York",
|
|
77
|
+
"maximumAttendeesCount": 30,
|
|
78
|
+
})
|
|
79
|
+
self.assertEqual(rc, 0)
|
|
80
|
+
self.assertEqual(recs[0]["JobPositionId"], "@JobPosition_1")
|
|
81
|
+
self.assertEqual(recs[0]["Status"], "Upcoming")
|
|
82
|
+
self.assertEqual(recs[0]["MaximumAttendeesCount"], 30)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PerDayWindows(unittest.TestCase):
|
|
86
|
+
"""The cross-product bug: windows must NOT bleed across days in per-day mode."""
|
|
87
|
+
|
|
88
|
+
def test_windows_vary_by_day(self):
|
|
89
|
+
rc, recs, _ = run({
|
|
90
|
+
"entity": "JobPositionShift",
|
|
91
|
+
"parentId": "0PS000000000001",
|
|
92
|
+
"windowsByDay": {
|
|
93
|
+
"Saturday": [{"start": "08:00", "end": "12:00"}, {"start": "12:00", "end": "17:00"}],
|
|
94
|
+
"Sunday": [{"start": "09:00", "end": "14:00"}],
|
|
95
|
+
},
|
|
96
|
+
"dateRange": {"start": "2026-09-05", "end": "2026-09-13"}, # Sat 5,12 / Sun 6,13
|
|
97
|
+
"timeZone": "America/New_York",
|
|
98
|
+
})
|
|
99
|
+
self.assertEqual(rc, 0)
|
|
100
|
+
got = set(keys(recs))
|
|
101
|
+
# Saturdays get BOTH windows; Sundays get ONLY the 09-14 window.
|
|
102
|
+
self.assertIn(("2026-09-05", "2026-09-05", "08:00:00.000Z", "12:00:00.000Z"), got)
|
|
103
|
+
self.assertIn(("2026-09-05", "2026-09-05", "12:00:00.000Z", "17:00:00.000Z"), got)
|
|
104
|
+
self.assertIn(("2026-09-06", "2026-09-06", "09:00:00.000Z", "14:00:00.000Z"), got)
|
|
105
|
+
# The bug would have put Saturday's windows on Sunday too — assert it does NOT.
|
|
106
|
+
self.assertNotIn(("2026-09-06", "2026-09-06", "08:00:00.000Z", "12:00:00.000Z"), got)
|
|
107
|
+
self.assertNotIn(("2026-09-13", "2026-09-13", "12:00:00.000Z", "17:00:00.000Z"), got)
|
|
108
|
+
self.assertEqual(len(recs), 6) # 2 Sat x 2 + 2 Sun x 1
|
|
109
|
+
|
|
110
|
+
def test_by_day_rejects_redundant_daysofweek(self):
|
|
111
|
+
rc, _, err = run({
|
|
112
|
+
"entity": "JobPositionShift",
|
|
113
|
+
"parentId": "0PS000000000001",
|
|
114
|
+
"daysOfWeek": ["Saturday"],
|
|
115
|
+
"windowsByDay": {"Saturday": [{"start": "08:00", "end": "12:00"}]},
|
|
116
|
+
"dateRange": {"start": "2026-09-05", "end": "2026-09-13"},
|
|
117
|
+
"timeZone": "America/New_York",
|
|
118
|
+
})
|
|
119
|
+
self.assertEqual(rc, 1)
|
|
120
|
+
self.assertIn("daysOfWeek", err)
|
|
121
|
+
|
|
122
|
+
def test_explicit_date_without_matching_day_fails(self):
|
|
123
|
+
rc, _, err = run({
|
|
124
|
+
"entity": "JobPositionShift",
|
|
125
|
+
"parentId": "0PS000000000001",
|
|
126
|
+
"oneOffDates": ["2026-09-07"], # a Monday
|
|
127
|
+
"windowsByDay": {"Saturday": [{"start": "08:00", "end": "12:00"}]},
|
|
128
|
+
"timeZone": "America/New_York",
|
|
129
|
+
})
|
|
130
|
+
self.assertEqual(rc, 1)
|
|
131
|
+
self.assertIn("windowsByDay", err)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WindowsFormValidation(unittest.TestCase):
|
|
135
|
+
def test_both_windows_forms_rejected(self):
|
|
136
|
+
rc, _, err = run({
|
|
137
|
+
"entity": "JobPositionShift",
|
|
138
|
+
"parentId": "0PS000000000001",
|
|
139
|
+
"daysOfWeek": ["Tuesday"],
|
|
140
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
141
|
+
"windowsByDay": {"Tuesday": [{"start": "10:00", "end": "12:00"}]},
|
|
142
|
+
"dateRange": {"start": "2026-09-01", "end": "2026-09-08"},
|
|
143
|
+
"timeZone": "America/New_York",
|
|
144
|
+
})
|
|
145
|
+
self.assertEqual(rc, 1)
|
|
146
|
+
self.assertIn("exactly one", err)
|
|
147
|
+
|
|
148
|
+
def test_neither_windows_form_rejected(self):
|
|
149
|
+
rc, _, err = run({
|
|
150
|
+
"entity": "JobPositionShift",
|
|
151
|
+
"parentId": "0PS000000000001",
|
|
152
|
+
"daysOfWeek": ["Tuesday"],
|
|
153
|
+
"dateRange": {"start": "2026-09-01", "end": "2026-09-08"},
|
|
154
|
+
"timeZone": "America/New_York",
|
|
155
|
+
})
|
|
156
|
+
self.assertEqual(rc, 1)
|
|
157
|
+
self.assertIn("exactly one", err)
|
|
158
|
+
|
|
159
|
+
def test_window_end_before_start_rejected(self):
|
|
160
|
+
rc, _, err = run({
|
|
161
|
+
"entity": "JobPositionShift",
|
|
162
|
+
"parentId": "0PS000000000001",
|
|
163
|
+
"oneOffDates": ["2026-09-06"],
|
|
164
|
+
"windows": [{"start": "17:00", "end": "12:00"}],
|
|
165
|
+
"timeZone": "America/New_York",
|
|
166
|
+
})
|
|
167
|
+
self.assertEqual(rc, 1)
|
|
168
|
+
self.assertIn("after start", err)
|
|
169
|
+
|
|
170
|
+
def test_overlapping_windows_allowed(self):
|
|
171
|
+
# Overlap is intentional-friendly: VM permits overlapping shift records.
|
|
172
|
+
rc, recs, _ = run({
|
|
173
|
+
"entity": "JobPositionShift",
|
|
174
|
+
"parentId": "0PS000000000001",
|
|
175
|
+
"oneOffDates": ["2026-09-06"],
|
|
176
|
+
"windows": [{"start": "08:00", "end": "13:00"}, {"start": "12:00", "end": "17:00"}],
|
|
177
|
+
"timeZone": "America/New_York",
|
|
178
|
+
})
|
|
179
|
+
self.assertEqual(rc, 0)
|
|
180
|
+
self.assertEqual(len(recs), 2)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Dedup(unittest.TestCase):
|
|
184
|
+
BASE = {
|
|
185
|
+
"entity": "JobPositionShift",
|
|
186
|
+
"parentId": "0PS000000000001",
|
|
187
|
+
"daysOfWeek": ["Tuesday", "Thursday"],
|
|
188
|
+
"windows": [{"start": "15:30", "end": "17:30"}],
|
|
189
|
+
"dateRange": {"start": "2026-09-01", "end": "2026-09-14"}, # Tue 1,8 / Thu 3,10
|
|
190
|
+
"timeZone": "America/New_York",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def test_excludes_matching_shifts(self):
|
|
194
|
+
spec = dict(self.BASE)
|
|
195
|
+
# Exclude the two Tuesdays (query-style literal times, EndDate omitted).
|
|
196
|
+
spec["excludeExisting"] = [
|
|
197
|
+
{"StartDate": "2026-09-01", "StartTime": "15:30:00.000Z", "EndTime": "17:30:00.000Z"},
|
|
198
|
+
{"StartDate": "2026-09-08", "StartTime": "15:30:00.000Z", "EndTime": "17:30:00.000Z"},
|
|
199
|
+
]
|
|
200
|
+
rc, recs, err = run(spec)
|
|
201
|
+
self.assertEqual(rc, 0)
|
|
202
|
+
self.assertEqual(len(recs), 2) # only the two Thursdays remain
|
|
203
|
+
self.assertEqual({r["StartDate"] for r in recs}, {"2026-09-03", "2026-09-10"})
|
|
204
|
+
self.assertIn("excluded 2", err)
|
|
205
|
+
|
|
206
|
+
def test_all_excluded_fails_cleanly(self):
|
|
207
|
+
spec = dict(self.BASE)
|
|
208
|
+
spec["excludeExisting"] = [
|
|
209
|
+
{"StartDate": d, "StartTime": "15:30", "EndTime": "17:30"}
|
|
210
|
+
for d in ("2026-09-01", "2026-09-08", "2026-09-03", "2026-09-10")
|
|
211
|
+
]
|
|
212
|
+
rc, recs, err = run(spec)
|
|
213
|
+
self.assertEqual(rc, 1)
|
|
214
|
+
self.assertIsNone(recs)
|
|
215
|
+
self.assertIn("already exist", err)
|
|
216
|
+
self.assertIn("nothing to create", err)
|
|
217
|
+
|
|
218
|
+
def test_time_only_no_date_match_keeps_all(self):
|
|
219
|
+
# Same times but a date outside the series -> no match, nothing excluded.
|
|
220
|
+
spec = dict(self.BASE)
|
|
221
|
+
spec["excludeExisting"] = [
|
|
222
|
+
{"StartDate": "2025-01-01", "StartTime": "15:30", "EndTime": "17:30"},
|
|
223
|
+
]
|
|
224
|
+
rc, recs, _ = run(spec)
|
|
225
|
+
self.assertEqual(rc, 0)
|
|
226
|
+
self.assertEqual(len(recs), 4)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TimeSlots(unittest.TestCase):
|
|
230
|
+
def test_flat_timeslots(self):
|
|
231
|
+
rc, recs, _ = run({
|
|
232
|
+
"entity": "TimeSlot",
|
|
233
|
+
"parentId": "0OH000000000001",
|
|
234
|
+
"daysOfWeek": ["Monday", "Tuesday"],
|
|
235
|
+
"windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
|
|
236
|
+
})
|
|
237
|
+
self.assertEqual(rc, 0)
|
|
238
|
+
self.assertEqual(len(recs), 4)
|
|
239
|
+
self.assertEqual({r["DayOfWeek"] for r in recs}, {"Monday", "Tuesday"})
|
|
240
|
+
|
|
241
|
+
def test_per_day_timeslots(self):
|
|
242
|
+
rc, recs, _ = run({
|
|
243
|
+
"entity": "TimeSlot",
|
|
244
|
+
"parentId": "0OH000000000001",
|
|
245
|
+
"windowsByDay": {
|
|
246
|
+
"Monday": [{"start": "09:00", "end": "17:00"}],
|
|
247
|
+
"Saturday": [{"start": "10:00", "end": "14:00"}],
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
self.assertEqual(rc, 0)
|
|
251
|
+
self.assertEqual(len(recs), 2)
|
|
252
|
+
by_day = {r["DayOfWeek"]: (r["StartTime"], r["EndTime"]) for r in recs}
|
|
253
|
+
self.assertEqual(by_day["Monday"], ("09:00:00.000Z", "17:00:00.000Z"))
|
|
254
|
+
self.assertEqual(by_day["Saturday"], ("10:00:00.000Z", "14:00:00.000Z"))
|
|
255
|
+
|
|
256
|
+
def test_timeslot_rejects_ref_parent(self):
|
|
257
|
+
rc, _, err = run({
|
|
258
|
+
"entity": "TimeSlot",
|
|
259
|
+
"parentRef": "OperatingHours_1",
|
|
260
|
+
"daysOfWeek": ["Monday"],
|
|
261
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
262
|
+
})
|
|
263
|
+
self.assertEqual(rc, 1)
|
|
264
|
+
self.assertIn("master-detail", err)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class Batching(unittest.TestCase):
|
|
268
|
+
def test_over_200_requires_out_dir(self):
|
|
269
|
+
spec = {
|
|
270
|
+
"entity": "JobPositionShift",
|
|
271
|
+
"parentId": "0PS000000000001",
|
|
272
|
+
"daysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
273
|
+
"windows": [{"start": "09:00", "end": "12:00"}],
|
|
274
|
+
"dateRange": {"start": "2026-01-01", "end": "2026-12-31"}, # >200 weekdays
|
|
275
|
+
"timeZone": "America/New_York",
|
|
276
|
+
}
|
|
277
|
+
rc, _, err = run(spec)
|
|
278
|
+
self.assertEqual(rc, 1)
|
|
279
|
+
self.assertIn("--out-dir", err)
|
|
280
|
+
|
|
281
|
+
def test_out_dir_splits_into_batches(self):
|
|
282
|
+
spec = {
|
|
283
|
+
"entity": "JobPositionShift",
|
|
284
|
+
"parentId": "0PS000000000001",
|
|
285
|
+
"daysOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
|
286
|
+
"windows": [{"start": "09:00", "end": "12:00"}],
|
|
287
|
+
"dateRange": {"start": "2026-01-01", "end": "2026-12-31"},
|
|
288
|
+
"timeZone": "America/New_York",
|
|
289
|
+
}
|
|
290
|
+
with tempfile.TemporaryDirectory() as d:
|
|
291
|
+
rc, _, err = run(spec, ["--out-dir", d])
|
|
292
|
+
self.assertEqual(rc, 0)
|
|
293
|
+
files = sorted(f for f in os.listdir(d) if f.endswith(".json"))
|
|
294
|
+
self.assertGreater(len(files), 1)
|
|
295
|
+
total = 0
|
|
296
|
+
for f in files:
|
|
297
|
+
with open(os.path.join(d, f)) as fh:
|
|
298
|
+
batch = json.load(fh)["records"]
|
|
299
|
+
self.assertLessEqual(len(batch), 200)
|
|
300
|
+
total += len(batch)
|
|
301
|
+
self.assertEqual(total, 261) # 261 weekdays that are Mon-Fri in 2026
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class CompositeDates(unittest.TestCase):
|
|
305
|
+
"""Scenario 1: multiple ranges + one-off dates unioned in a single spec."""
|
|
306
|
+
|
|
307
|
+
def test_multiple_ranges_union(self):
|
|
308
|
+
rc, recs, _ = run({
|
|
309
|
+
"entity": "JobPositionShift",
|
|
310
|
+
"parentId": "0PS000000000001",
|
|
311
|
+
"daysOfWeek": ["Tuesday"],
|
|
312
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
313
|
+
"dateRanges": [
|
|
314
|
+
{"start": "2027-06-01", "end": "2027-06-14"}, # Tue 1, 8
|
|
315
|
+
{"start": "2027-09-01", "end": "2027-09-14"}, # Tue 7, 14
|
|
316
|
+
],
|
|
317
|
+
"timeZone": "America/New_York",
|
|
318
|
+
})
|
|
319
|
+
self.assertEqual(rc, 0)
|
|
320
|
+
self.assertEqual({r["StartDate"] for r in recs},
|
|
321
|
+
{"2027-06-01", "2027-06-08", "2027-09-07", "2027-09-14"})
|
|
322
|
+
|
|
323
|
+
def test_ranges_plus_one_offs(self):
|
|
324
|
+
rc, recs, _ = run({
|
|
325
|
+
"entity": "JobPositionShift",
|
|
326
|
+
"parentId": "0PS000000000001",
|
|
327
|
+
"daysOfWeek": ["Tuesday"],
|
|
328
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
329
|
+
"dateRange": {"start": "2027-06-01", "end": "2027-06-14"}, # Tue 1, 8
|
|
330
|
+
"oneOffDates": ["2027-08-15"], # a Sunday — taken as-is, no weekday filter
|
|
331
|
+
"timeZone": "America/New_York",
|
|
332
|
+
})
|
|
333
|
+
self.assertEqual(rc, 0)
|
|
334
|
+
self.assertEqual({r["StartDate"] for r in recs},
|
|
335
|
+
{"2027-06-01", "2027-06-08", "2027-08-15"})
|
|
336
|
+
|
|
337
|
+
def test_one_off_inside_range_collapses(self):
|
|
338
|
+
# A one-off that also lands in the range appears once, not twice.
|
|
339
|
+
rc, recs, _ = run({
|
|
340
|
+
"entity": "JobPositionShift",
|
|
341
|
+
"parentId": "0PS000000000001",
|
|
342
|
+
"daysOfWeek": ["Tuesday"],
|
|
343
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
344
|
+
"dateRange": {"start": "2027-06-01", "end": "2027-06-14"}, # Tue 1, 8
|
|
345
|
+
"oneOffDates": ["2027-06-08"], # already a Tuesday in the range
|
|
346
|
+
"timeZone": "America/New_York",
|
|
347
|
+
})
|
|
348
|
+
self.assertEqual(rc, 0)
|
|
349
|
+
self.assertEqual(len(recs), 2)
|
|
350
|
+
|
|
351
|
+
def test_only_one_offs_needs_no_daysofweek(self):
|
|
352
|
+
rc, recs, _ = run({
|
|
353
|
+
"entity": "JobPositionShift",
|
|
354
|
+
"parentId": "0PS000000000001",
|
|
355
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
356
|
+
"oneOffDates": ["2027-06-02", "2027-06-05"],
|
|
357
|
+
"timeZone": "America/New_York",
|
|
358
|
+
})
|
|
359
|
+
self.assertEqual(rc, 0)
|
|
360
|
+
self.assertEqual(len(recs), 2)
|
|
361
|
+
|
|
362
|
+
def test_no_dates_at_all_fails(self):
|
|
363
|
+
rc, _, err = run({
|
|
364
|
+
"entity": "JobPositionShift",
|
|
365
|
+
"parentId": "0PS000000000001",
|
|
366
|
+
"daysOfWeek": ["Tuesday"],
|
|
367
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
368
|
+
"timeZone": "America/New_York",
|
|
369
|
+
})
|
|
370
|
+
self.assertEqual(rc, 1)
|
|
371
|
+
self.assertIn("dateRanges", err)
|
|
372
|
+
|
|
373
|
+
def test_old_dates_key_guided_to_new_names(self):
|
|
374
|
+
rc, _, err = run({
|
|
375
|
+
"entity": "JobPositionShift",
|
|
376
|
+
"parentId": "0PS000000000001",
|
|
377
|
+
"dates": ["2027-06-01"],
|
|
378
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
379
|
+
"timeZone": "America/New_York",
|
|
380
|
+
})
|
|
381
|
+
self.assertEqual(rc, 1)
|
|
382
|
+
self.assertIn("oneOffDates", err)
|
|
383
|
+
|
|
384
|
+
def test_dateranges_must_be_list_of_objects(self):
|
|
385
|
+
rc, _, err = run({
|
|
386
|
+
"entity": "JobPositionShift",
|
|
387
|
+
"parentId": "0PS000000000001",
|
|
388
|
+
"daysOfWeek": ["Tuesday"],
|
|
389
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
390
|
+
"dateRange": [{"start": "2027-06-01", "end": "2027-06-14"}], # list under singular key
|
|
391
|
+
"timeZone": "America/New_York",
|
|
392
|
+
})
|
|
393
|
+
self.assertEqual(rc, 1)
|
|
394
|
+
self.assertIn("dateRanges", err) # steered to the list key
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class ExcludeDates(unittest.TestCase):
|
|
398
|
+
"""Scenario 2: in-memory removal of whole dates before writing."""
|
|
399
|
+
|
|
400
|
+
def test_excludes_a_whole_date(self):
|
|
401
|
+
rc, recs, _ = run({
|
|
402
|
+
"entity": "JobPositionShift",
|
|
403
|
+
"parentId": "0PS000000000001",
|
|
404
|
+
"daysOfWeek": ["Tuesday"],
|
|
405
|
+
"windows": [{"start": "10:00", "end": "12:00"}, {"start": "14:00", "end": "16:00"}],
|
|
406
|
+
"dateRange": {"start": "2027-06-01", "end": "2027-06-14"}, # Tue 1, 8
|
|
407
|
+
"excludeDates": ["2027-06-08"],
|
|
408
|
+
"timeZone": "America/New_York",
|
|
409
|
+
})
|
|
410
|
+
self.assertEqual(rc, 0)
|
|
411
|
+
# Both windows on the 1st survive; the 8th (both windows) is gone.
|
|
412
|
+
self.assertEqual({r["StartDate"] for r in recs}, {"2027-06-01"})
|
|
413
|
+
self.assertEqual(len(recs), 2)
|
|
414
|
+
|
|
415
|
+
def test_excluding_all_dates_fails_cleanly(self):
|
|
416
|
+
rc, recs, err = run({
|
|
417
|
+
"entity": "JobPositionShift",
|
|
418
|
+
"parentId": "0PS000000000001",
|
|
419
|
+
"daysOfWeek": ["Tuesday"],
|
|
420
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
421
|
+
"dateRange": {"start": "2027-06-01", "end": "2027-06-14"},
|
|
422
|
+
"excludeDates": ["2027-06-01", "2027-06-08"],
|
|
423
|
+
"timeZone": "America/New_York",
|
|
424
|
+
})
|
|
425
|
+
self.assertEqual(rc, 1)
|
|
426
|
+
self.assertIsNone(recs)
|
|
427
|
+
self.assertIn("no shifts produced", err)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class ExactDuplicateCollapse(unittest.TestCase):
|
|
431
|
+
def test_duplicate_window_emits_once(self):
|
|
432
|
+
rc, recs, _ = run({
|
|
433
|
+
"entity": "JobPositionShift",
|
|
434
|
+
"parentId": "0PS000000000001",
|
|
435
|
+
"oneOffDates": ["2027-06-01"],
|
|
436
|
+
"windows": [{"start": "10:00", "end": "12:00"}, {"start": "10:00", "end": "12:00"}],
|
|
437
|
+
"timeZone": "America/New_York",
|
|
438
|
+
})
|
|
439
|
+
self.assertEqual(rc, 0)
|
|
440
|
+
self.assertEqual(len(recs), 1)
|
|
441
|
+
|
|
442
|
+
def test_duplicate_day_timeslot_emits_once(self):
|
|
443
|
+
rc, recs, _ = run({
|
|
444
|
+
"entity": "TimeSlot",
|
|
445
|
+
"parentId": "0OH000000000001",
|
|
446
|
+
"daysOfWeek": ["Monday", "Monday"],
|
|
447
|
+
"windows": [{"start": "10:00", "end": "12:00"}],
|
|
448
|
+
})
|
|
449
|
+
self.assertEqual(rc, 0)
|
|
450
|
+
self.assertEqual(len(recs), 1)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class MultiDayShifts(unittest.TestCase):
|
|
454
|
+
"""endDayOffset: a JobPositionShift may cross midnight / span days (EndDate > StartDate)."""
|
|
455
|
+
|
|
456
|
+
def test_overnight_offset_one(self):
|
|
457
|
+
# 23:00 -> 03:00 the NEXT day: EndDate = StartDate + 1, end time earlier than start.
|
|
458
|
+
rc, recs, _ = run({
|
|
459
|
+
"entity": "JobPositionShift",
|
|
460
|
+
"parentId": "0PS000000000001",
|
|
461
|
+
"oneOffDates": ["2026-11-18"],
|
|
462
|
+
"windows": [{"start": "23:00", "end": "03:00", "endDayOffset": 1}],
|
|
463
|
+
"timeZone": "America/New_York",
|
|
464
|
+
})
|
|
465
|
+
self.assertEqual(rc, 0)
|
|
466
|
+
self.assertEqual(len(recs), 1)
|
|
467
|
+
self.assertEqual(recs[0]["StartDate"], "2026-11-18")
|
|
468
|
+
self.assertEqual(recs[0]["EndDate"], "2026-11-19")
|
|
469
|
+
self.assertEqual(recs[0]["StartTime"], "23:00:00.000Z")
|
|
470
|
+
self.assertEqual(recs[0]["EndTime"], "03:00:00.000Z")
|
|
471
|
+
|
|
472
|
+
def test_multi_day_span_offset_n(self):
|
|
473
|
+
# Tue 02:00 -> Thu 18:00 (2-day span) recurring across a range.
|
|
474
|
+
rc, recs, _ = run({
|
|
475
|
+
"entity": "JobPositionShift",
|
|
476
|
+
"parentId": "0PS000000000001",
|
|
477
|
+
"daysOfWeek": ["Tuesday"],
|
|
478
|
+
"windows": [{"start": "02:00", "end": "18:00", "endDayOffset": 2}],
|
|
479
|
+
"dateRange": {"start": "2026-07-01", "end": "2026-07-31"}, # Tue 7,14,21,28
|
|
480
|
+
"timeZone": "America/New_York",
|
|
481
|
+
})
|
|
482
|
+
self.assertEqual(rc, 0)
|
|
483
|
+
self.assertEqual(len(recs), 4)
|
|
484
|
+
for r in recs:
|
|
485
|
+
sd = dt_date(r["StartDate"])
|
|
486
|
+
ed = dt_date(r["EndDate"])
|
|
487
|
+
self.assertEqual((ed - sd).days, 2) # every EndDate is StartDate + 2
|
|
488
|
+
|
|
489
|
+
def test_end_date_may_pass_range_end(self):
|
|
490
|
+
# dateRange bounds START dates; a spanning shift's EndDate can fall past range.end.
|
|
491
|
+
rc, recs, _ = run({
|
|
492
|
+
"entity": "JobPositionShift",
|
|
493
|
+
"parentId": "0PS000000000001",
|
|
494
|
+
"daysOfWeek": ["Tuesday"],
|
|
495
|
+
"windows": [{"start": "02:00", "end": "18:00", "endDayOffset": 2}],
|
|
496
|
+
"dateRange": {"start": "2026-09-28", "end": "2026-09-30"}, # only Tue 9-29
|
|
497
|
+
"timeZone": "America/New_York",
|
|
498
|
+
})
|
|
499
|
+
self.assertEqual(rc, 0)
|
|
500
|
+
self.assertEqual(len(recs), 1)
|
|
501
|
+
self.assertEqual(recs[0]["StartDate"], "2026-09-29")
|
|
502
|
+
self.assertEqual(recs[0]["EndDate"], "2026-10-01") # past range.end, as intended
|
|
503
|
+
|
|
504
|
+
def test_offset_zero_is_default_single_day(self):
|
|
505
|
+
# No endDayOffset -> EndDate == StartDate (regression guard for same-day shifts).
|
|
506
|
+
rc, recs, _ = run({
|
|
507
|
+
"entity": "JobPositionShift",
|
|
508
|
+
"parentId": "0PS000000000001",
|
|
509
|
+
"oneOffDates": ["2026-11-18"],
|
|
510
|
+
"windows": [{"start": "09:00", "end": "17:00"}],
|
|
511
|
+
"timeZone": "America/New_York",
|
|
512
|
+
})
|
|
513
|
+
self.assertEqual(rc, 0)
|
|
514
|
+
self.assertEqual(recs[0]["StartDate"], recs[0]["EndDate"])
|
|
515
|
+
|
|
516
|
+
def test_same_day_end_before_start_still_rejected(self):
|
|
517
|
+
# offset 0 with end <= start is a typo, not an overnight shift — reject it loudly.
|
|
518
|
+
rc, _, err = run({
|
|
519
|
+
"entity": "JobPositionShift",
|
|
520
|
+
"parentId": "0PS000000000001",
|
|
521
|
+
"oneOffDates": ["2026-11-18"],
|
|
522
|
+
"windows": [{"start": "23:00", "end": "03:00"}], # no offset
|
|
523
|
+
"timeZone": "America/New_York",
|
|
524
|
+
})
|
|
525
|
+
self.assertEqual(rc, 1)
|
|
526
|
+
self.assertIn("endDayOffset", err) # steered toward the span opt-in
|
|
527
|
+
|
|
528
|
+
def test_negative_offset_rejected(self):
|
|
529
|
+
rc, _, err = run({
|
|
530
|
+
"entity": "JobPositionShift",
|
|
531
|
+
"parentId": "0PS000000000001",
|
|
532
|
+
"oneOffDates": ["2026-11-18"],
|
|
533
|
+
"windows": [{"start": "09:00", "end": "17:00", "endDayOffset": -1}],
|
|
534
|
+
"timeZone": "America/New_York",
|
|
535
|
+
})
|
|
536
|
+
self.assertEqual(rc, 1)
|
|
537
|
+
self.assertIn("endDayOffset", err)
|
|
538
|
+
|
|
539
|
+
def test_bool_offset_rejected(self):
|
|
540
|
+
# bool is an int subclass; a stray `true` must not be read as offset 1.
|
|
541
|
+
rc, _, err = run({
|
|
542
|
+
"entity": "JobPositionShift",
|
|
543
|
+
"parentId": "0PS000000000001",
|
|
544
|
+
"oneOffDates": ["2026-11-18"],
|
|
545
|
+
"windows": [{"start": "09:00", "end": "17:00", "endDayOffset": True}],
|
|
546
|
+
"timeZone": "America/New_York",
|
|
547
|
+
})
|
|
548
|
+
self.assertEqual(rc, 1)
|
|
549
|
+
self.assertIn("endDayOffset", err)
|
|
550
|
+
|
|
551
|
+
def test_timeslot_rejects_offset(self):
|
|
552
|
+
# A TimeSlot is one weekday's window — no EndDate — so endDayOffset is invalid.
|
|
553
|
+
rc, _, err = run({
|
|
554
|
+
"entity": "TimeSlot",
|
|
555
|
+
"parentId": "0OH000000000001",
|
|
556
|
+
"daysOfWeek": ["Monday"],
|
|
557
|
+
"windows": [{"start": "22:00", "end": "02:00", "endDayOffset": 1}],
|
|
558
|
+
})
|
|
559
|
+
self.assertEqual(rc, 1)
|
|
560
|
+
self.assertIn("endDayOffset", err)
|
|
561
|
+
|
|
562
|
+
def test_exclude_existing_matches_multi_day_key(self):
|
|
563
|
+
# Dedup key includes EndDate: a spanning shift is excluded by its full key.
|
|
564
|
+
rc, recs, err = run({
|
|
565
|
+
"entity": "JobPositionShift",
|
|
566
|
+
"parentId": "0PS000000000001",
|
|
567
|
+
"oneOffDates": ["2026-11-18", "2026-11-25"],
|
|
568
|
+
"windows": [{"start": "23:00", "end": "03:00", "endDayOffset": 1}],
|
|
569
|
+
"timeZone": "America/New_York",
|
|
570
|
+
"excludeExisting": [
|
|
571
|
+
{"StartDate": "2026-11-18", "EndDate": "2026-11-19",
|
|
572
|
+
"StartTime": "23:00:00.000Z", "EndTime": "03:00:00.000Z"},
|
|
573
|
+
],
|
|
574
|
+
})
|
|
575
|
+
self.assertEqual(rc, 0)
|
|
576
|
+
self.assertEqual(len(recs), 1)
|
|
577
|
+
self.assertEqual(recs[0]["StartDate"], "2026-11-25")
|
|
578
|
+
self.assertIn("excluded 1", err)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
SAT = {
|
|
582
|
+
"entity": "JobPositionShift",
|
|
583
|
+
"parentId": "0PS000000000001",
|
|
584
|
+
"daysOfWeek": ["Saturday"],
|
|
585
|
+
"windows": [{"start": "09:00", "end": "12:00"}],
|
|
586
|
+
"timeZone": "America/New_York",
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class CountBound(unittest.TestCase):
|
|
591
|
+
"""``count`` bounds a series by number of dates instead of (or as well as) an end date."""
|
|
592
|
+
|
|
593
|
+
def test_count_without_end_date(self):
|
|
594
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=3))
|
|
595
|
+
self.assertEqual(rc, 0)
|
|
596
|
+
self.assertEqual([r["StartDate"] for r in recs],
|
|
597
|
+
["2027-06-05", "2027-06-12", "2027-06-19"])
|
|
598
|
+
|
|
599
|
+
def test_count_stops_before_end_date(self):
|
|
600
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05", "end": "2027-08-28"},
|
|
601
|
+
count=2))
|
|
602
|
+
self.assertEqual(rc, 0)
|
|
603
|
+
self.assertEqual(len(recs), 2)
|
|
604
|
+
|
|
605
|
+
def test_end_date_stops_before_count(self):
|
|
606
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05", "end": "2027-06-12"},
|
|
607
|
+
count=10))
|
|
608
|
+
self.assertEqual(rc, 0)
|
|
609
|
+
self.assertEqual(len(recs), 2)
|
|
610
|
+
|
|
611
|
+
def test_count_is_dates_not_shifts(self):
|
|
612
|
+
spec = dict(SAT, dateRange={"start": "2027-06-05"}, count=2,
|
|
613
|
+
windows=[{"start": "09:00", "end": "12:00"},
|
|
614
|
+
{"start": "13:00", "end": "16:00"}])
|
|
615
|
+
rc, recs, _ = run(spec)
|
|
616
|
+
self.assertEqual(rc, 0)
|
|
617
|
+
self.assertEqual(len(recs), 4) # 2 dates x 2 windows
|
|
618
|
+
self.assertEqual(sorted({r["StartDate"] for r in recs}), ["2027-06-05", "2027-06-12"])
|
|
619
|
+
|
|
620
|
+
def test_excluded_dates_do_not_use_up_the_count(self):
|
|
621
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=3,
|
|
622
|
+
excludeDates=["2027-06-12"]))
|
|
623
|
+
self.assertEqual(rc, 0)
|
|
624
|
+
self.assertEqual([r["StartDate"] for r in recs],
|
|
625
|
+
["2027-06-05", "2027-06-19", "2027-06-26"])
|
|
626
|
+
|
|
627
|
+
def test_count_applies_before_exclude_existing(self):
|
|
628
|
+
rc, recs, err = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=3,
|
|
629
|
+
excludeExisting=[{"StartDate": "2027-06-05",
|
|
630
|
+
"StartTime": "09:00:00.000Z",
|
|
631
|
+
"EndTime": "12:00:00.000Z"}]))
|
|
632
|
+
self.assertEqual(rc, 0)
|
|
633
|
+
self.assertEqual([r["StartDate"] for r in recs], ["2027-06-12", "2027-06-19"])
|
|
634
|
+
self.assertIn("excluded 1", err)
|
|
635
|
+
|
|
636
|
+
def test_count_with_one_off_dates(self):
|
|
637
|
+
spec = dict(SAT, oneOffDates=["2027-07-01", "2027-07-02", "2027-07-03"], count=2)
|
|
638
|
+
del spec["daysOfWeek"]
|
|
639
|
+
rc, recs, _ = run(spec)
|
|
640
|
+
self.assertEqual(rc, 0)
|
|
641
|
+
self.assertEqual([r["StartDate"] for r in recs], ["2027-07-01", "2027-07-02"])
|
|
642
|
+
|
|
643
|
+
def test_missing_end_without_count_fails(self):
|
|
644
|
+
rc, _, err = run(dict(SAT, dateRange={"start": "2027-06-05"}))
|
|
645
|
+
self.assertEqual(rc, 1)
|
|
646
|
+
self.assertIn("end date or a count", err)
|
|
647
|
+
|
|
648
|
+
def test_invalid_count_rejected(self):
|
|
649
|
+
for bad in (0, -1, 2.5, "3", True):
|
|
650
|
+
rc, _, err = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=bad))
|
|
651
|
+
self.assertEqual(rc, 1, bad)
|
|
652
|
+
self.assertIn("count must be an integer", err)
|
|
653
|
+
|
|
654
|
+
def test_timeslot_rejects_count(self):
|
|
655
|
+
rc, _, err = run({
|
|
656
|
+
"entity": "TimeSlot",
|
|
657
|
+
"parentId": "0OH000000000001",
|
|
658
|
+
"daysOfWeek": ["Monday"],
|
|
659
|
+
"windows": [{"start": "09:00", "end": "17:00"}],
|
|
660
|
+
"count": 3,
|
|
661
|
+
})
|
|
662
|
+
self.assertEqual(rc, 1)
|
|
663
|
+
self.assertIn("count", err)
|
|
664
|
+
|
|
665
|
+
def test_status_defaults_to_upcoming(self):
|
|
666
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=1))
|
|
667
|
+
self.assertEqual(rc, 0)
|
|
668
|
+
self.assertEqual(recs[0]["Status"], "Upcoming")
|
|
669
|
+
|
|
670
|
+
def test_status_passes_through(self):
|
|
671
|
+
rc, recs, _ = run(dict(SAT, dateRange={"start": "2027-06-05"}, count=1,
|
|
672
|
+
status="In Progress"))
|
|
673
|
+
self.assertEqual(rc, 0)
|
|
674
|
+
self.assertEqual(recs[0]["Status"], "In Progress")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class SpecGuards(unittest.TestCase):
|
|
678
|
+
def test_non_object_json_fails_cleanly(self):
|
|
679
|
+
# A top-level array/scalar must fail() with a message, not crash with a traceback.
|
|
680
|
+
for bad in ("[]", "42", "\"x\""):
|
|
681
|
+
proc = subprocess.run(
|
|
682
|
+
[sys.executable, SCRIPT], input=bad, capture_output=True, text=True,
|
|
683
|
+
)
|
|
684
|
+
self.assertEqual(proc.returncode, 1)
|
|
685
|
+
self.assertIn("JSON object", proc.stderr)
|
|
686
|
+
self.assertNotIn("Traceback", proc.stderr)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
if __name__ == "__main__":
|
|
690
|
+
unittest.main()
|