shortcuts-playground 1.2.1

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.
Files changed (68) hide show
  1. package/README.md +54 -0
  2. package/agents/shortcut-builder.md +172 -0
  3. package/agents/shortcut-remixer.md +239 -0
  4. package/commands/build-shortcut.md +32 -0
  5. package/commands/remix-shortcut.md +34 -0
  6. package/opencode.json +30 -0
  7. package/package.json +47 -0
  8. package/plugin/validator.ts +179 -0
  9. package/skills/shortcuts-playground/ACTIONS.md +713 -0
  10. package/skills/shortcuts-playground/APPINTENTS.md +2760 -0
  11. package/skills/shortcuts-playground/AUTOMATION_TRIGGERS.md +133 -0
  12. package/skills/shortcuts-playground/BEST_PRACTICES.md +357 -0
  13. package/skills/shortcuts-playground/CHANGELOG.md +585 -0
  14. package/skills/shortcuts-playground/CONTROL_FLOW.md +777 -0
  15. package/skills/shortcuts-playground/DATE_TIME.md +75 -0
  16. package/skills/shortcuts-playground/EXAMPLES.md +738 -0
  17. package/skills/shortcuts-playground/FILTERS.md +697 -0
  18. package/skills/shortcuts-playground/HEALTHKIT.md +317 -0
  19. package/skills/shortcuts-playground/ICONS_AND_COLORS.md +89 -0
  20. package/skills/shortcuts-playground/JAVASCRIPT_WEBPAGE.md +51 -0
  21. package/skills/shortcuts-playground/PARAMETER_TYPES.md +1117 -0
  22. package/skills/shortcuts-playground/PLIST_FORMAT.md +296 -0
  23. package/skills/shortcuts-playground/README.md +77 -0
  24. package/skills/shortcuts-playground/SKILL.md +459 -0
  25. package/skills/shortcuts-playground/THIRD_PARTY_ACTIONS.md +77 -0
  26. package/skills/shortcuts-playground/TOOLKIT_SNAPSHOT.md +62 -0
  27. package/skills/shortcuts-playground/URL_SCHEMES.md +59 -0
  28. package/skills/shortcuts-playground/VARIABLES.md +569 -0
  29. package/skills/shortcuts-playground/assets/shortcuts-small.svg +14 -0
  30. package/skills/shortcuts-playground/assets/shortcuts.png +0 -0
  31. package/skills/shortcuts-playground/data/healthkit-ios26.2-reference.json +2603 -0
  32. package/skills/shortcuts-playground/data/macos27-shortpy-grounding.json +4085 -0
  33. package/skills/shortcuts-playground/data/macos27-workflow-trigger-samples.json +1 -0
  34. package/skills/shortcuts-playground/data/shortcuts-glyph-synonyms.json +5102 -0
  35. package/skills/shortcuts-playground/data/shortcuts-icon-colors.json +107 -0
  36. package/skills/shortcuts-playground/data/shortcuts-official-glyph-mapping.json +509 -0
  37. package/skills/shortcuts-playground/data/toolkit-v63-tool-ids.json +1806 -0
  38. package/skills/shortcuts-playground/data/toolkit-v78-first-party-enum-cases.json +1 -0
  39. package/skills/shortcuts-playground/data/toolkit-v78-first-party-parameter-keys.json +1 -0
  40. package/skills/shortcuts-playground/data/toolkit-v78-ios27-tool-ids.json +1222 -0
  41. package/skills/shortcuts-playground/data/toolkit-v78-tool-ids.json +2745 -0
  42. package/skills/shortcuts-playground/data/toolkit-v78-trigger-parameter-keys.json +1051 -0
  43. package/skills/shortcuts-playground/golden-shortcuts/index.jsonl +19 -0
  44. package/skills/shortcuts-playground/golden-shortcuts/xml/1be4dde95b794253bf82438e201b33e7.xml +174 -0
  45. package/skills/shortcuts-playground/golden-shortcuts/xml/2e0fb675e45948aaacee7e534f910492.xml +1016 -0
  46. package/skills/shortcuts-playground/golden-shortcuts/xml/332c12a0060043b388b22b806be7ab58.xml +1924 -0
  47. package/skills/shortcuts-playground/golden-shortcuts/xml/3dd4ee24e43f464f92adaa70a0311eaa.xml +1177 -0
  48. package/skills/shortcuts-playground/golden-shortcuts/xml/4d102301c6e646faa7a8a221f4f4ec98.xml +321 -0
  49. package/skills/shortcuts-playground/golden-shortcuts/xml/51cc4e26d1044893a0c3f2f3630cf2d2.xml +1933 -0
  50. package/skills/shortcuts-playground/golden-shortcuts/xml/623e7f1ca5f948e2bd53811fec63e544.xml +136 -0
  51. package/skills/shortcuts-playground/golden-shortcuts/xml/6a18b76843ac45c384ac3400f3740997.xml +1791 -0
  52. package/skills/shortcuts-playground/golden-shortcuts/xml/71f0cacb0f604b399b76c5dcb7286e7c.xml +2228 -0
  53. package/skills/shortcuts-playground/golden-shortcuts/xml/807525ed9f974829bc8494defac923a8.xml +259 -0
  54. package/skills/shortcuts-playground/golden-shortcuts/xml/8ab0d39826ab4249be58672763caa3ba.xml +420 -0
  55. package/skills/shortcuts-playground/golden-shortcuts/xml/91c45fd3fd3b427897d9ba485efb1227.xml +800 -0
  56. package/skills/shortcuts-playground/golden-shortcuts/xml/97be626bb25c41709d175646a7f6d8f2.xml +424 -0
  57. package/skills/shortcuts-playground/golden-shortcuts/xml/ae59e10d409348f9bd33894f03f9beb4.xml +568 -0
  58. package/skills/shortcuts-playground/golden-shortcuts/xml/afa83b6be811483b9c32189c41eb9312.xml +391 -0
  59. package/skills/shortcuts-playground/golden-shortcuts/xml/cfdb083b857e4ac189629fa386d27cdc.xml +193 -0
  60. package/skills/shortcuts-playground/golden-shortcuts/xml/e01cebe192d64b2fbca80204d03d92ab.xml +111 -0
  61. package/skills/shortcuts-playground/golden-shortcuts/xml/ef669bcf8bad489d9ef4b88bfaf5772f.xml +780 -0
  62. package/skills/shortcuts-playground/golden-shortcuts/xml/f44f5caf5e3e48d4817e73af450c4404.xml +570 -0
  63. package/skills/shortcuts-playground/scripts/generate_healthkit_reference.py +394 -0
  64. package/skills/shortcuts-playground/scripts/lookup_action_grounding.py +1164 -0
  65. package/skills/shortcuts-playground/scripts/select_shortcut_icon_color.py +597 -0
  66. package/skills/shortcuts-playground/scripts/test_random_mixed_shortcuts.py +1067 -0
  67. package/skills/shortcuts-playground/scripts/test_wiring_regressions.py +2247 -0
  68. package/skills/shortcuts-playground/scripts/validate_shortcut.py +4526 -0
@@ -0,0 +1,2247 @@
1
+ #!/usr/bin/env python3
2
+ """Regression suite for weather/location, Set Name, and HealthKit coverage.
3
+
4
+ This script generates large synthetic shortcut corpora and runs them through
5
+ validate_shortcut.py's validate() API.
6
+
7
+ It is designed to enforce:
8
+ - Get Detail of Weather Conditions must have non-empty, correctly wired input.
9
+ - Location-bearing parameters must never be emitted empty/unwired.
10
+ - Set Name must use setitemname with both input and target name.
11
+ - Rename File must not be used as a renamed-copy step before Save/Share.
12
+ - HealthKit actions must validate exported iOS plist parameter shapes across
13
+ all SDK-known HealthKit type/value families.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import plistlib
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from validate_shortcut import load_allowed_ids, load_icon_metadata, validate
27
+
28
+ PLACEHOLDER = "\uFFFC"
29
+ WEATHER_DETAILS = [
30
+ "Date",
31
+ "Location",
32
+ "Condition",
33
+ "Temperature",
34
+ "High",
35
+ "Low",
36
+ "Feels Like",
37
+ "Humidity",
38
+ "Pressure",
39
+ "Precipitation Amount",
40
+ "Precipitation Chance",
41
+ "Wind Direction",
42
+ "UV Index",
43
+ "Sunrise Time",
44
+ "Sunset Time",
45
+ "Wind Speed",
46
+ "Visibility",
47
+ "Dewpoint",
48
+ "Air Quality Index",
49
+ "Air Quality Category",
50
+ "Air Pollutants",
51
+ "Name",
52
+ ]
53
+ HEALTH_SAMPLE_DETAILS = ["Type", "Value", "Unit", "Start Date", "End Date", "Duration", "Source", "Name"]
54
+
55
+
56
+ @dataclass
57
+ class Case:
58
+ category: str
59
+ name: str
60
+ plist: dict
61
+ expect_valid: bool
62
+ expected_error_substring: Optional[str] = None
63
+
64
+
65
+ def seeded_uuid(seed: str) -> str:
66
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, seed)).upper()
67
+
68
+
69
+ def prompt_comment_text(prompt: str) -> str:
70
+ return (
71
+ "Shortcuts generated by Shortcuts Playground. May contain mistakes. "
72
+ "Always check the shortcut's actions first.\n\n"
73
+ "This shortcut was created via the following user prompt:\n\n"
74
+ f"> {prompt}"
75
+ )
76
+
77
+
78
+ def base_actions(name: str, prompt: str) -> list[dict]:
79
+ return [
80
+ {
81
+ "WFWorkflowActionIdentifier": "is.workflow.actions.comment",
82
+ "WFWorkflowActionParameters": {
83
+ "WFCommentActionText": f"{name}: regression test fixture."
84
+ },
85
+ },
86
+ {
87
+ "WFWorkflowActionIdentifier": "is.workflow.actions.comment",
88
+ "WFWorkflowActionParameters": {"WFCommentActionText": prompt_comment_text(prompt)},
89
+ },
90
+ ]
91
+
92
+
93
+ def root_plist(name: str, actions: list[dict]) -> dict:
94
+ return {
95
+ "WFWorkflowActions": actions,
96
+ "WFWorkflowClientVersion": "2700.0.4",
97
+ "WFWorkflowHasOutputFallback": False,
98
+ "WFWorkflowIcon": {
99
+ "WFWorkflowIconGlyphNumber": 61440,
100
+ "WFWorkflowIconStartColor": 431817727,
101
+ },
102
+ "WFWorkflowImportQuestions": [],
103
+ "WFWorkflowInputContentItemClasses": [],
104
+ "WFWorkflowMinimumClientVersion": 900,
105
+ "WFWorkflowMinimumClientVersionString": "900",
106
+ "WFWorkflowName": name,
107
+ "WFWorkflowOutputContentItemClasses": [],
108
+ "WFWorkflowTypes": [],
109
+ }
110
+
111
+
112
+ def make_shortcuts_url_valid(case_name: str) -> dict:
113
+ plist = root_plist(
114
+ case_name,
115
+ base_actions(case_name, "Open an Apple-documented Shortcuts URL scheme.")
116
+ + [
117
+ {
118
+ "WFWorkflowActionIdentifier": "is.workflow.actions.url",
119
+ "WFWorkflowActionParameters": {
120
+ "UUID": seeded_uuid(case_name + "-url"),
121
+ "WFURLActionURL": (
122
+ "shortcuts://x-callback-url/run-shortcut?"
123
+ "name=Calculate%20Tip&input=text&text=24.99&"
124
+ "x-success=myapp%3A%2F%2Fdone&x-error=myapp%3A%2F%2Ferror"
125
+ ),
126
+ },
127
+ }
128
+ ],
129
+ )
130
+ return plist
131
+
132
+
133
+ def make_shortcuts_url_invalid(case_name: str) -> dict:
134
+ plist = root_plist(
135
+ case_name,
136
+ base_actions(case_name, "Reject unsupported Shortcuts URL scheme routes.")
137
+ + [
138
+ {
139
+ "WFWorkflowActionIdentifier": "is.workflow.actions.url",
140
+ "WFWorkflowActionParameters": {
141
+ "UUID": seeded_uuid(case_name + "-url"),
142
+ "WFURLActionURL": "shortcuts://import-shortcut?url=https%3A%2F%2Fexample.com%2FTest.shortcut",
143
+ },
144
+ }
145
+ ],
146
+ )
147
+ return plist
148
+
149
+
150
+ def make_javascript_webpage_valid(case_name: str) -> dict:
151
+ plist = root_plist(
152
+ case_name,
153
+ base_actions(case_name, "Run JavaScript on the current Safari webpage.")
154
+ + [
155
+ {
156
+ "WFWorkflowActionIdentifier": "is.workflow.actions.runjavascriptonwebpage",
157
+ "WFWorkflowActionParameters": {
158
+ "UUID": seeded_uuid(case_name + "-js"),
159
+ "WFJavaScript": "const title = document.title; completion({title});",
160
+ },
161
+ }
162
+ ],
163
+ )
164
+ plist["WFWorkflowInputContentItemClasses"] = ["WFSafariWebPageContentItem"]
165
+ plist["WFWorkflowTypes"] = ["ActionExtension"]
166
+ return plist
167
+
168
+
169
+ def make_javascript_webpage_invalid(case_name: str) -> dict:
170
+ plist = root_plist(
171
+ case_name,
172
+ base_actions(case_name, "Reject JavaScript webpage shortcuts without Safari/share-sheet wiring.")
173
+ + [
174
+ {
175
+ "WFWorkflowActionIdentifier": "is.workflow.actions.runjavascriptonwebpage",
176
+ "WFWorkflowActionParameters": {
177
+ "UUID": seeded_uuid(case_name + "-js"),
178
+ "WFJavaScript": "window.alert('Done');",
179
+ },
180
+ }
181
+ ],
182
+ )
183
+ plist["WFWorkflowInputContentItemClasses"] = ["WFStringContentItem"]
184
+ plist["WFWorkflowTypes"] = []
185
+ return plist
186
+
187
+
188
+ def attachment_action_output(output_uuid: str, output_name: str) -> dict:
189
+ return {
190
+ "WFSerializationType": "WFTextTokenAttachment",
191
+ "Value": {
192
+ "Type": "ActionOutput",
193
+ "OutputUUID": output_uuid,
194
+ "OutputName": output_name,
195
+ },
196
+ }
197
+
198
+
199
+ def attachment_variable(name: str) -> dict:
200
+ return {
201
+ "WFSerializationType": "WFTextTokenAttachment",
202
+ "Value": {"Type": "Variable", "VariableName": name},
203
+ }
204
+
205
+
206
+ def token_string_action_output(output_uuid: str, output_name: str) -> dict:
207
+ return {
208
+ "WFSerializationType": "WFTextTokenString",
209
+ "Value": {
210
+ "string": PLACEHOLDER,
211
+ "attachmentsByRange": {
212
+ "{0, 1}": {
213
+ "Type": "ActionOutput",
214
+ "OutputUUID": output_uuid,
215
+ "OutputName": output_name,
216
+ }
217
+ },
218
+ },
219
+ }
220
+
221
+
222
+ def token_string_variable(name: str) -> dict:
223
+ return {
224
+ "WFSerializationType": "WFTextTokenString",
225
+ "Value": {
226
+ "string": PLACEHOLDER,
227
+ "attachmentsByRange": {"{0, 1}": {"Type": "Variable", "VariableName": name}},
228
+ },
229
+ }
230
+
231
+
232
+ def make_send_message_single_type_valid(case_name: str) -> dict:
233
+ prompt = "Send Message single-type content through an appended variable payload."
234
+ actions = base_actions(case_name, prompt)
235
+ first_uuid = seeded_uuid(f"{case_name}-first-text")
236
+ empty_uuid = seeded_uuid(f"{case_name}-empty-text")
237
+ var_name = "Message Items"
238
+
239
+ actions.extend(
240
+ [
241
+ {
242
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
243
+ "WFWorkflowActionParameters": {
244
+ "UUID": first_uuid,
245
+ "WFTextActionText": "Single content item",
246
+ },
247
+ },
248
+ {
249
+ "WFWorkflowActionIdentifier": "is.workflow.actions.appendvariable",
250
+ "WFWorkflowActionParameters": {
251
+ "WFVariableName": var_name,
252
+ "WFInput": attachment_action_output(first_uuid, "Text"),
253
+ },
254
+ },
255
+ {
256
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
257
+ "WFWorkflowActionParameters": {
258
+ "UUID": empty_uuid,
259
+ "WFTextActionText": "",
260
+ },
261
+ },
262
+ {
263
+ "WFWorkflowActionIdentifier": "is.workflow.actions.appendvariable",
264
+ "WFWorkflowActionParameters": {
265
+ "WFVariableName": var_name,
266
+ "WFInput": attachment_action_output(empty_uuid, "Text"),
267
+ },
268
+ },
269
+ {
270
+ "WFWorkflowActionIdentifier": "is.workflow.actions.sendmessage",
271
+ "WFWorkflowActionParameters": {
272
+ "WFSendMessageContent": token_string_variable(var_name),
273
+ },
274
+ },
275
+ ]
276
+ )
277
+ return root_plist(case_name, actions)
278
+
279
+
280
+ def make_send_message_one_append_invalid(case_name: str) -> tuple[dict, str]:
281
+ prompt = "Reject Send Message content variables with only one Append Variable action."
282
+ actions = base_actions(case_name, prompt)
283
+ text_uuid = seeded_uuid(f"{case_name}-text")
284
+ var_name = "Message Items"
285
+
286
+ actions.extend(
287
+ [
288
+ {
289
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
290
+ "WFWorkflowActionParameters": {
291
+ "UUID": text_uuid,
292
+ "WFTextActionText": "Only one append",
293
+ },
294
+ },
295
+ {
296
+ "WFWorkflowActionIdentifier": "is.workflow.actions.appendvariable",
297
+ "WFWorkflowActionParameters": {
298
+ "WFVariableName": var_name,
299
+ "WFInput": attachment_action_output(text_uuid, "Text"),
300
+ },
301
+ },
302
+ {
303
+ "WFWorkflowActionIdentifier": "is.workflow.actions.sendmessage",
304
+ "WFWorkflowActionParameters": {
305
+ "WFSendMessageContent": token_string_variable(var_name),
306
+ },
307
+ },
308
+ ]
309
+ )
310
+ return root_plist(case_name, actions), "2+ appends"
311
+
312
+
313
+ def make_send_message_action_output_invalid(case_name: str) -> tuple[dict, str]:
314
+ prompt = "Reject Send Message content wired directly to an action output."
315
+ actions = base_actions(case_name, prompt)
316
+ text_uuid = seeded_uuid(f"{case_name}-text")
317
+
318
+ actions.extend(
319
+ [
320
+ {
321
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
322
+ "WFWorkflowActionParameters": {
323
+ "UUID": text_uuid,
324
+ "WFTextActionText": "Direct output",
325
+ },
326
+ },
327
+ {
328
+ "WFWorkflowActionIdentifier": "is.workflow.actions.sendmessage",
329
+ "WFWorkflowActionParameters": {
330
+ "WFSendMessageContent": token_string_action_output(text_uuid, "Text"),
331
+ },
332
+ },
333
+ ]
334
+ )
335
+ return root_plist(case_name, actions), "should reference a named variable"
336
+
337
+
338
+ def make_send_message_attachment_invalid(case_name: str) -> tuple[dict, str]:
339
+ prompt = "Reject Send Message content using WFTextTokenAttachment."
340
+ plist = make_send_message_single_type_valid(case_name)
341
+ plist["WFWorkflowActions"][-1]["WFWorkflowActionParameters"]["WFSendMessageContent"] = (
342
+ attachment_variable("Message Items")
343
+ )
344
+ return plist, "should use WFTextTokenString"
345
+
346
+
347
+ def calendar_next_event_filter(operator: int) -> dict:
348
+ return {
349
+ "WFSerializationType": "WFContentPredicateTableTemplate",
350
+ "Value": {
351
+ "WFActionParameterFilterPrefix": 1,
352
+ "WFContentPredicateBoundedDate": False,
353
+ "WFActionParameterFilterTemplates": [
354
+ {
355
+ "Operator": 1002,
356
+ "Property": "Start Date",
357
+ "Removable": True,
358
+ "Values": {},
359
+ },
360
+ {
361
+ "Operator": operator,
362
+ "Property": "Start Date",
363
+ "Removable": True,
364
+ "Values": {"Date": current_date_attachment()},
365
+ },
366
+ ],
367
+ },
368
+ }
369
+
370
+
371
+ def make_calendar_next_event_filter_valid(case_name: str) -> dict:
372
+ actions = base_actions(case_name, "Find the next calendar event after now.")
373
+ actions.append(
374
+ {
375
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.calendarevents",
376
+ "WFWorkflowActionParameters": {
377
+ "UUID": seeded_uuid(f"{case_name}-find-events"),
378
+ "WFContentItemFilter": calendar_next_event_filter(2),
379
+ "WFContentItemSortProperty": "Start Date",
380
+ "WFContentItemSortOrder": "Oldest First",
381
+ "WFContentItemLimitEnabled": True,
382
+ "WFContentItemLimitNumber": 1,
383
+ },
384
+ }
385
+ )
386
+ return root_plist(case_name, actions)
387
+
388
+
389
+ def make_calendar_next_event_filter_invalid(case_name: str) -> tuple[dict, str]:
390
+ plist = make_calendar_next_event_filter_valid(case_name)
391
+ templates = plist["WFWorkflowActions"][-1]["WFWorkflowActionParameters"]["WFContentItemFilter"][
392
+ "Value"
393
+ ]["WFActionParameterFilterTemplates"]
394
+ templates[1]["Operator"] = 3
395
+ return plist, "numeric operator 3"
396
+
397
+
398
+ def make_get_time_between_dates_valid(case_name: str) -> dict:
399
+ actions = base_actions(case_name, "Compute minutes between two dates.")
400
+ start_uuid = seeded_uuid(f"{case_name}-start-date")
401
+ now_uuid = seeded_uuid(f"{case_name}-now-date")
402
+ actions.extend(
403
+ [
404
+ {
405
+ "WFWorkflowActionIdentifier": "is.workflow.actions.date",
406
+ "WFWorkflowActionParameters": {
407
+ "UUID": start_uuid,
408
+ "WFDateActionMode": "Current Date",
409
+ },
410
+ },
411
+ {
412
+ "WFWorkflowActionIdentifier": "is.workflow.actions.date",
413
+ "WFWorkflowActionParameters": {
414
+ "UUID": now_uuid,
415
+ "WFDateActionMode": "Current Date",
416
+ },
417
+ },
418
+ {
419
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettimebetweendates",
420
+ "WFWorkflowActionParameters": {
421
+ "UUID": seeded_uuid(f"{case_name}-time-between"),
422
+ "WFInput": token_string_action_output(start_uuid, "Date"),
423
+ "WFTimeUntilFromDate": token_string_action_output(now_uuid, "Date"),
424
+ "WFTimeUntilUnit": "Minutes",
425
+ },
426
+ },
427
+ ]
428
+ )
429
+ return root_plist(case_name, actions)
430
+
431
+
432
+ def make_get_time_between_dates_current_date_invalid(case_name: str) -> tuple[dict, str]:
433
+ plist = make_get_time_between_dates_valid(case_name)
434
+ plist["WFWorkflowActions"][-1]["WFWorkflowActionParameters"]["WFTimeUntilFromDate"] = (
435
+ current_date_attachment()
436
+ )
437
+ return plist, "cannot use CurrentDate"
438
+
439
+
440
+ def make_get_time_between_dates_attachment_invalid(case_name: str) -> tuple[dict, str]:
441
+ plist = make_get_time_between_dates_valid(case_name)
442
+ actions = plist["WFWorkflowActions"]
443
+ start_uuid = actions[-3]["WFWorkflowActionParameters"]["UUID"]
444
+ now_uuid = actions[-2]["WFWorkflowActionParameters"]["UUID"]
445
+ actions[-1]["WFWorkflowActionParameters"]["WFInput"] = attachment_action_output(start_uuid, "Date")
446
+ actions[-1]["WFWorkflowActionParameters"]["WFTimeUntilFromDate"] = attachment_action_output(
447
+ now_uuid, "Date"
448
+ )
449
+ return plist, "should use WFTextTokenString"
450
+
451
+
452
+ def make_text_token_placeholder_invalid(case_name: str) -> tuple[dict, str]:
453
+ actions = base_actions(case_name, "Reject token strings whose range points away from the placeholder.")
454
+ date_uuid = seeded_uuid(f"{case_name}-date")
455
+ actions.extend(
456
+ [
457
+ {
458
+ "WFWorkflowActionIdentifier": "is.workflow.actions.date",
459
+ "WFWorkflowActionParameters": {
460
+ "UUID": date_uuid,
461
+ "WFDateActionMode": "Current Date",
462
+ },
463
+ },
464
+ {
465
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
466
+ "WFWorkflowActionParameters": {
467
+ "UUID": seeded_uuid(f"{case_name}-text"),
468
+ "WFTextActionText": {
469
+ "WFSerializationType": "WFTextTokenString",
470
+ "Value": {
471
+ "string": f"A{PLACEHOLDER}",
472
+ "attachmentsByRange": {
473
+ "{0, 1}": {
474
+ "Type": "ActionOutput",
475
+ "OutputUUID": date_uuid,
476
+ "OutputName": "Date",
477
+ }
478
+ },
479
+ },
480
+ },
481
+ },
482
+ },
483
+ ]
484
+ )
485
+ return root_plist(case_name, actions), "expected placeholder position"
486
+
487
+
488
+ def current_date_attachment() -> dict:
489
+ return {
490
+ "WFSerializationType": "WFTextTokenAttachment",
491
+ "Value": {"Type": "CurrentDate"},
492
+ }
493
+
494
+
495
+ def quantity_field(magnitude: object = "1", unit: str = "count") -> dict:
496
+ value = {"Unit": unit}
497
+ if magnitude is not None:
498
+ value["Magnitude"] = magnitude
499
+ return {"WFSerializationType": "WFQuantityFieldValue", "Value": value}
500
+
501
+
502
+ def health_filter_template(
503
+ health_type: str | None = "Steps",
504
+ operator: int = 1002,
505
+ property_name: str = "Start Date",
506
+ ) -> dict:
507
+ templates = []
508
+ if health_type is not None:
509
+ templates.append(
510
+ {
511
+ "Bounded": True,
512
+ "Operator": 4,
513
+ "Property": "Type",
514
+ "Removable": False,
515
+ "Values": {
516
+ "Enumeration": {
517
+ "Value": health_type,
518
+ "WFSerializationType": "WFStringSubstitutableState",
519
+ },
520
+ },
521
+ }
522
+ )
523
+ date_values = {"Number": "7", "Unit": 16} if property_name == "Start Date" and operator == 1002 else {}
524
+ templates.append(
525
+ {
526
+ "Bounded": True,
527
+ "Operator": operator,
528
+ "Property": property_name,
529
+ "Removable": False,
530
+ "Values": date_values,
531
+ }
532
+ )
533
+ return {
534
+ "WFSerializationType": "WFContentPredicateTableTemplate",
535
+ "Value": {
536
+ "WFActionParameterFilterPrefix": 1,
537
+ "WFContentPredicateBoundedDate": False,
538
+ "WFActionParameterFilterTemplates": templates,
539
+ },
540
+ }
541
+
542
+
543
+ def malformed_health_value_filter_template(health_type: str = "Step Count") -> dict:
544
+ return {
545
+ "WFSerializationType": "WFContentPredicateTableTemplate",
546
+ "Value": {
547
+ "WFActionParameterFilterPrefix": 1,
548
+ "WFContentPredicateBoundedDate": False,
549
+ "WFActionParameterFilterTemplates": [
550
+ {
551
+ "Operator": 4,
552
+ "Property": "Value",
553
+ "Removable": True,
554
+ "Values": {
555
+ "String": health_type,
556
+ "Unit": 4,
557
+ },
558
+ },
559
+ {
560
+ "Operator": 1002,
561
+ "Property": "Start Date",
562
+ "Removable": True,
563
+ "Values": {},
564
+ },
565
+ ],
566
+ },
567
+ }
568
+
569
+
570
+ def mixed_health_type_and_value_filter_template() -> dict:
571
+ filter_value = health_filter_template("Steps")
572
+ templates = filter_value["Value"]["WFActionParameterFilterTemplates"]
573
+ templates.insert(
574
+ 1,
575
+ {
576
+ "Operator": 4,
577
+ "Property": "Value",
578
+ "Removable": True,
579
+ "Values": {
580
+ "String": "Step Count",
581
+ "Unit": 4,
582
+ },
583
+ },
584
+ )
585
+ return filter_value
586
+
587
+
588
+ def health_unit_hint(row: dict) -> str:
589
+ hint = str(row.get("unit_and_aggregation") or "").split(",", 1)[0].strip()
590
+ return hint or "count"
591
+
592
+
593
+ def health_find_sample_label(row: dict) -> str | None:
594
+ observed = row.get("observed_find_samples_labels")
595
+ if isinstance(observed, list):
596
+ for label in observed:
597
+ if isinstance(label, str) and label:
598
+ return label
599
+ return row.get("shortcut_label_guess") or row.get("sdk_suffix")
600
+
601
+
602
+ def load_healthkit_reference() -> dict:
603
+ path = Path(__file__).resolve().parents[1] / "data/healthkit-ios26.2-reference.json"
604
+ with path.open("r", encoding="utf-8") as handle:
605
+ return json.load(handle)
606
+
607
+
608
+ def validate_healthkit_reference_labels(health: dict) -> None:
609
+ quantity_by_suffix = {
610
+ row.get("sdk_suffix"): row
611
+ for row in health.get("quantity_types", [])
612
+ if isinstance(row, dict)
613
+ }
614
+ expected = {
615
+ "BloodPressureDiastolic": "Diastolic Blood Pressure",
616
+ "BloodPressureSystolic": "Systolic Blood Pressure",
617
+ }
618
+ failures = []
619
+ for sdk_suffix, expected_label in expected.items():
620
+ row = quantity_by_suffix.get(sdk_suffix)
621
+ actual = row.get("shortcut_label_guess") if row else None
622
+ if actual != expected_label:
623
+ failures.append(
624
+ f"{sdk_suffix}: expected shortcut_label_guess {expected_label!r}, got {actual!r}"
625
+ )
626
+ if failures:
627
+ raise AssertionError("HealthKit reference label mismatch:\n" + "\n".join(failures))
628
+
629
+
630
+ def make_weather_valid(case_name: str, idx: int) -> dict:
631
+ prompt = f"Weather valid case {idx}"
632
+ actions = base_actions(case_name, prompt)
633
+
634
+ loc_uuid = seeded_uuid(f"{case_name}-location")
635
+ weather_uuid = seeded_uuid(f"{case_name}-weather")
636
+ detail_uuid = seeded_uuid(f"{case_name}-detail")
637
+ weather_var_name = f"Weather Data {idx}"
638
+
639
+ use_forecast = idx % 2 == 1
640
+ use_custom_location = idx % 3 == 0
641
+ use_token_string = idx % 5 in {2, 4}
642
+ include_weather_var = idx % 4 in {1, 2}
643
+ detail_name = WEATHER_DETAILS[idx % len(WEATHER_DETAILS)]
644
+
645
+ if use_custom_location:
646
+ actions.append(
647
+ {
648
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getcurrentlocation",
649
+ "WFWorkflowActionParameters": {"UUID": loc_uuid},
650
+ }
651
+ )
652
+
653
+ weather_params = {"UUID": weather_uuid}
654
+ if use_custom_location:
655
+ weather_params["WFWeatherCustomLocation"] = attachment_action_output(
656
+ loc_uuid, "Current Location"
657
+ )
658
+ if use_forecast:
659
+ weather_params["WFWeatherForecastType"] = "Daily"
660
+ actions.append(
661
+ {
662
+ "WFWorkflowActionIdentifier": (
663
+ "is.workflow.actions.weather.forecast"
664
+ if use_forecast
665
+ else "is.workflow.actions.weather.currentconditions"
666
+ ),
667
+ "WFWorkflowActionParameters": weather_params,
668
+ }
669
+ )
670
+
671
+ if include_weather_var:
672
+ actions.append(
673
+ {
674
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
675
+ "WFWorkflowActionParameters": {
676
+ "WFVariableName": weather_var_name,
677
+ "WFInput": attachment_action_output(weather_uuid, "Weather Conditions"),
678
+ },
679
+ }
680
+ )
681
+ # Keep Get Detail of Weather Conditions input wired directly to Weather output.
682
+ input_payload = (
683
+ token_string_action_output(weather_uuid, "Weather Conditions")
684
+ if use_token_string
685
+ else attachment_action_output(weather_uuid, "Weather Conditions")
686
+ )
687
+
688
+ actions.append(
689
+ {
690
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.weather.conditions",
691
+ "WFWorkflowActionParameters": {
692
+ "UUID": detail_uuid,
693
+ "WFInput": input_payload,
694
+ "WFContentItemPropertyName": detail_name,
695
+ },
696
+ }
697
+ )
698
+ return root_plist(case_name, actions)
699
+
700
+
701
+ def make_weather_sun_times_valid(case_name: str) -> dict:
702
+ prompt = "Weather sunrise/sunset list extraction valid case"
703
+ actions = base_actions(case_name, prompt)
704
+
705
+ weather_uuid = seeded_uuid(f"{case_name}-weather")
706
+ sunrise_uuid = seeded_uuid(f"{case_name}-sunrise")
707
+ sunrise_item_uuid = seeded_uuid(f"{case_name}-sunrise-item")
708
+ sunrise_format_uuid = seeded_uuid(f"{case_name}-sunrise-format")
709
+ sunset_uuid = seeded_uuid(f"{case_name}-sunset")
710
+ sunset_item_uuid = seeded_uuid(f"{case_name}-sunset-item")
711
+ sunset_format_uuid = seeded_uuid(f"{case_name}-sunset-format")
712
+ text_uuid = seeded_uuid(f"{case_name}-text")
713
+
714
+ actions.extend(
715
+ [
716
+ {
717
+ "WFWorkflowActionIdentifier": "is.workflow.actions.comment",
718
+ "WFWorkflowActionParameters": {
719
+ "WFCommentActionText": "Fetch a daily forecast, extract sunrise and sunset lists, choose the first sunrise and last sunset, then format both times.",
720
+ },
721
+ },
722
+ {
723
+ "WFWorkflowActionIdentifier": "is.workflow.actions.weather.forecast",
724
+ "WFWorkflowActionParameters": {
725
+ "UUID": weather_uuid,
726
+ "WFWeatherForecastType": "Daily",
727
+ },
728
+ },
729
+ {
730
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.weather.conditions",
731
+ "WFWorkflowActionParameters": {
732
+ "UUID": sunrise_uuid,
733
+ "WFInput": attachment_action_output(weather_uuid, "Weather Conditions"),
734
+ "WFContentItemPropertyName": "Sunrise Time",
735
+ },
736
+ },
737
+ {
738
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
739
+ "WFWorkflowActionParameters": {
740
+ "UUID": sunrise_item_uuid,
741
+ "WFInput": attachment_action_output(sunrise_uuid, "Sunrise Time"),
742
+ "WFItemSpecifier": "First Item",
743
+ },
744
+ },
745
+ {
746
+ "WFWorkflowActionIdentifier": "is.workflow.actions.format.date",
747
+ "WFWorkflowActionParameters": {
748
+ "UUID": sunrise_format_uuid,
749
+ "WFDate": token_string_action_output(sunrise_item_uuid, "Item from List"),
750
+ "WFDateFormatStyle": "None",
751
+ "WFTimeFormatStyle": "Short",
752
+ },
753
+ },
754
+ {
755
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.weather.conditions",
756
+ "WFWorkflowActionParameters": {
757
+ "UUID": sunset_uuid,
758
+ "WFInput": attachment_action_output(weather_uuid, "Weather Conditions"),
759
+ "WFContentItemPropertyName": "Sunset Time",
760
+ },
761
+ },
762
+ {
763
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
764
+ "WFWorkflowActionParameters": {
765
+ "UUID": sunset_item_uuid,
766
+ "WFInput": attachment_action_output(sunset_uuid, "Sunset Time"),
767
+ "WFItemSpecifier": "Last Item",
768
+ },
769
+ },
770
+ {
771
+ "WFWorkflowActionIdentifier": "is.workflow.actions.format.date",
772
+ "WFWorkflowActionParameters": {
773
+ "UUID": sunset_format_uuid,
774
+ "WFDate": token_string_action_output(sunset_item_uuid, "Item from List"),
775
+ "WFDateFormatStyle": "None",
776
+ "WFTimeFormatStyle": "Short",
777
+ },
778
+ },
779
+ {
780
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
781
+ "WFWorkflowActionParameters": {
782
+ "UUID": text_uuid,
783
+ "WFTextActionText": {
784
+ "WFSerializationType": "WFTextTokenString",
785
+ "Value": {
786
+ "string": "Sunrise: \uFFFC\nSunset: \uFFFC",
787
+ "attachmentsByRange": {
788
+ "{9, 1}": {
789
+ "Type": "ActionOutput",
790
+ "OutputUUID": sunrise_format_uuid,
791
+ "OutputName": "Formatted Date",
792
+ },
793
+ "{19, 1}": {
794
+ "Type": "ActionOutput",
795
+ "OutputUUID": sunset_format_uuid,
796
+ "OutputName": "Formatted Date",
797
+ },
798
+ },
799
+ },
800
+ },
801
+ },
802
+ },
803
+ ]
804
+ )
805
+ return root_plist(case_name, actions)
806
+
807
+
808
+ def make_weather_invalid(case_name: str, idx: int) -> tuple[dict, str]:
809
+ prompt = f"Weather invalid case {idx}"
810
+ actions = base_actions(case_name, prompt)
811
+
812
+ weather_uuid = seeded_uuid(f"{case_name}-weather")
813
+ detail_uuid = seeded_uuid(f"{case_name}-detail")
814
+ text_uuid = seeded_uuid(f"{case_name}-text")
815
+ var_name = f"Non Weather {idx}"
816
+ weather_var_name = f"Weather Source {idx}"
817
+ pattern = idx % 15
818
+ detail_name = WEATHER_DETAILS[idx % len(WEATHER_DETAILS)]
819
+
820
+ expected = "Get Detail of Weather Conditions"
821
+
822
+ if pattern in {0, 1, 2, 6, 7, 10, 11, 12, 13, 14}:
823
+ actions.append(
824
+ {
825
+ "WFWorkflowActionIdentifier": "is.workflow.actions.weather.currentconditions",
826
+ "WFWorkflowActionParameters": {"UUID": weather_uuid},
827
+ }
828
+ )
829
+ if pattern == 10:
830
+ actions.append(
831
+ {
832
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
833
+ "WFWorkflowActionParameters": {
834
+ "WFVariableName": weather_var_name,
835
+ "WFInput": attachment_action_output(weather_uuid, "Weather Conditions"),
836
+ },
837
+ }
838
+ )
839
+
840
+ if pattern == 3:
841
+ actions.append(
842
+ {
843
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
844
+ "WFWorkflowActionParameters": {
845
+ "UUID": text_uuid,
846
+ "WFTextActionText": "Not weather",
847
+ },
848
+ }
849
+ )
850
+ if pattern == 4:
851
+ # Unknown UUID reference case: no source action intentionally.
852
+ pass
853
+ if pattern == 5:
854
+ actions.extend(
855
+ [
856
+ {
857
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
858
+ "WFWorkflowActionParameters": {
859
+ "UUID": text_uuid,
860
+ "WFTextActionText": "Still not weather",
861
+ },
862
+ },
863
+ {
864
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
865
+ "WFWorkflowActionParameters": {
866
+ "WFVariableName": var_name,
867
+ "WFInput": attachment_action_output(text_uuid, "Text"),
868
+ },
869
+ },
870
+ ]
871
+ )
872
+ if pattern in {8, 9}:
873
+ actions.append(
874
+ {
875
+ "WFWorkflowActionIdentifier": "is.workflow.actions.weather.forecast",
876
+ "WFWorkflowActionParameters": {
877
+ "UUID": weather_uuid,
878
+ "WFWeatherForecastType": "Daily",
879
+ },
880
+ }
881
+ )
882
+
883
+ params: dict = {"UUID": detail_uuid}
884
+ if pattern != 0:
885
+ if pattern == 1:
886
+ params["WFInput"] = {}
887
+ expected = "WFInput is not a token attachment"
888
+ elif pattern == 2:
889
+ params["WFInput"] = {"WFSerializationType": "WFTextTokenAttachment", "Value": {}}
890
+ expected = "WFInput has no variable/output reference"
891
+ elif pattern == 3:
892
+ params["WFInput"] = attachment_action_output(text_uuid, "Text")
893
+ expected = "should reference output from Get Weather/Forecast"
894
+ elif pattern == 4:
895
+ params["WFInput"] = attachment_action_output(
896
+ seeded_uuid(f"{case_name}-missing"), "Weather Conditions"
897
+ )
898
+ expected = "references unknown OutputUUID"
899
+ elif pattern == 5:
900
+ params["WFInput"] = attachment_variable(var_name)
901
+ expected = "should reference output from Get Weather/Forecast"
902
+ elif pattern == 6:
903
+ params["WFInput"] = attachment_action_output(weather_uuid, "Weather Conditions")
904
+ elif pattern == 7:
905
+ params["WFInput"] = attachment_action_output(weather_uuid, "Weather Conditions")
906
+ elif pattern == 8:
907
+ params["WFInput"] = token_string_variable("Ghost Weather Variable")
908
+ expected = "should reference output from Get Weather/Forecast"
909
+ elif pattern == 9:
910
+ params["WFInput"] = {
911
+ "WFSerializationType": "WFTextTokenString",
912
+ "Value": {"string": "No attachment", "attachmentsByRange": {}},
913
+ }
914
+ expected = "WFInput has no variable/output reference"
915
+ elif pattern == 10:
916
+ params["WFInput"] = attachment_variable(weather_var_name)
917
+ expected = "reference weather action output directly"
918
+ elif pattern == 11:
919
+ params["WFInput"] = attachment_action_output(weather_uuid, "Weather Conditions")
920
+ elif pattern == 12:
921
+ params["WFInput"] = attachment_action_output(weather_uuid, "Weather Conditions")
922
+ elif pattern in {13, 14}:
923
+ params["WFInput"] = attachment_action_output(weather_uuid, "Weather Conditions")
924
+
925
+ if pattern == 6:
926
+ # Missing property key entirely.
927
+ expected = "missing WFContentItemPropertyName"
928
+ elif pattern == 7:
929
+ params["WFContentItemPropertyName"] = " "
930
+ expected = "missing WFContentItemPropertyName"
931
+ elif pattern == 11:
932
+ params["WFContentItemPropertyName"] = "Detail"
933
+ expected = "placeholder detail name"
934
+ elif pattern == 12:
935
+ params["WFContentItemPropertyName"] = "Sunrise Date"
936
+ expected = "unsupported detail name"
937
+ elif pattern in {13, 14}:
938
+ params["WFContentItemPropertyName"] = "Sunset Time"
939
+ else:
940
+ params["WFContentItemPropertyName"] = detail_name
941
+
942
+ if pattern == 0:
943
+ expected = "missing WFInput"
944
+
945
+ actions.append(
946
+ {
947
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.weather.conditions",
948
+ "WFWorkflowActionParameters": params,
949
+ }
950
+ )
951
+
952
+ if pattern == 13:
953
+ actions.append(
954
+ {
955
+ "WFWorkflowActionIdentifier": "is.workflow.actions.format.date",
956
+ "WFWorkflowActionParameters": {
957
+ "UUID": seeded_uuid(f"{case_name}-format-direct-sun"),
958
+ "WFDate": token_string_action_output(detail_uuid, "Sunset Time"),
959
+ },
960
+ }
961
+ )
962
+ expected = "Sunset Time returns a list"
963
+ if pattern == 14:
964
+ actions.append(
965
+ {
966
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getitemfromlist",
967
+ "WFWorkflowActionParameters": {
968
+ "UUID": seeded_uuid(f"{case_name}-wrong-sun-item"),
969
+ "WFInput": attachment_action_output(detail_uuid, "Sunset Time"),
970
+ "WFItemSpecifier": "First Item",
971
+ },
972
+ }
973
+ )
974
+ expected = "Sunset Time returns a list"
975
+ return root_plist(case_name, actions), expected
976
+
977
+
978
+ def make_location_valid(case_name: str, idx: int) -> dict:
979
+ prompt = f"Location valid case {idx}"
980
+ actions = base_actions(case_name, prompt)
981
+
982
+ loc_uuid = seeded_uuid(f"{case_name}-location-source")
983
+ var_name = f"Destination {idx}"
984
+ mode = idx % 4
985
+
986
+ actions.append(
987
+ {
988
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getcurrentlocation",
989
+ "WFWorkflowActionParameters": {"UUID": loc_uuid},
990
+ }
991
+ )
992
+
993
+ if mode in {2, 3}:
994
+ actions.append(
995
+ {
996
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
997
+ "WFWorkflowActionParameters": {
998
+ "WFVariableName": var_name,
999
+ "WFInput": attachment_action_output(loc_uuid, "Current Location"),
1000
+ },
1001
+ }
1002
+ )
1003
+
1004
+ if mode == 0:
1005
+ params = {
1006
+ "UUID": seeded_uuid(f"{case_name}-location-action"),
1007
+ "WFLocation": attachment_action_output(loc_uuid, "Current Location"),
1008
+ }
1009
+ ident = "is.workflow.actions.location"
1010
+ elif mode == 1:
1011
+ params = {
1012
+ "UUID": seeded_uuid(f"{case_name}-weather-current"),
1013
+ "WFWeatherCustomLocation": attachment_action_output(
1014
+ loc_uuid, "Current Location"
1015
+ ),
1016
+ }
1017
+ ident = "is.workflow.actions.weather.currentconditions"
1018
+ elif mode == 2:
1019
+ params = {
1020
+ "UUID": seeded_uuid(f"{case_name}-weather-forecast"),
1021
+ "WFWeatherForecastType": "Daily",
1022
+ "WFWeatherCustomLocation": attachment_variable(var_name),
1023
+ }
1024
+ ident = "is.workflow.actions.weather.forecast"
1025
+ else:
1026
+ params = {
1027
+ "UUID": seeded_uuid(f"{case_name}-weather-forecast"),
1028
+ "WFWeatherForecastType": "Hourly",
1029
+ "WFWeatherCustomLocation": attachment_variable(var_name),
1030
+ "WFWeatherLocation": attachment_variable(var_name),
1031
+ }
1032
+ ident = "is.workflow.actions.weather.forecast"
1033
+
1034
+ actions.append(
1035
+ {
1036
+ "WFWorkflowActionIdentifier": ident,
1037
+ "WFWorkflowActionParameters": params,
1038
+ }
1039
+ )
1040
+ return root_plist(case_name, actions)
1041
+
1042
+
1043
+ def make_location_invalid(case_name: str, idx: int) -> tuple[dict, str]:
1044
+ prompt = f"Location invalid case {idx}"
1045
+ actions = base_actions(case_name, prompt)
1046
+
1047
+ loc_uuid = seeded_uuid(f"{case_name}-location-source")
1048
+ missing_uuid = seeded_uuid(f"{case_name}-missing-location")
1049
+ var_name = f"Location Ref Invalid {idx}"
1050
+ pattern = idx % 10
1051
+
1052
+ actions.append(
1053
+ {
1054
+ "WFWorkflowActionIdentifier": "is.workflow.actions.getcurrentlocation",
1055
+ "WFWorkflowActionParameters": {"UUID": loc_uuid},
1056
+ }
1057
+ )
1058
+
1059
+ expected = "Location parameter"
1060
+
1061
+ if pattern == 0:
1062
+ ident = "is.workflow.actions.location"
1063
+ params = {"UUID": seeded_uuid(f"{case_name}-loc-missing")}
1064
+ expected = "Location action missing WFLocation"
1065
+ elif pattern == 1:
1066
+ ident = "is.workflow.actions.location"
1067
+ params = {
1068
+ "UUID": seeded_uuid(f"{case_name}-loc-empty-dict"),
1069
+ "WFLocation": {},
1070
+ }
1071
+ expected = "Location parameter WFLocation is not a token attachment"
1072
+ elif pattern == 2:
1073
+ ident = "is.workflow.actions.location"
1074
+ params = {
1075
+ "UUID": seeded_uuid(f"{case_name}-loc-empty-token"),
1076
+ "WFLocation": {"WFSerializationType": "WFTextTokenAttachment", "Value": {}},
1077
+ }
1078
+ expected = "Location parameter WFLocation is empty"
1079
+ elif pattern == 3:
1080
+ ident = "is.workflow.actions.location"
1081
+ params = {
1082
+ "UUID": seeded_uuid(f"{case_name}-loc-unknown"),
1083
+ "WFLocation": attachment_action_output(missing_uuid, "Current Location"),
1084
+ }
1085
+ expected = "references unknown OutputUUID"
1086
+ elif pattern == 4:
1087
+ ident = "is.workflow.actions.weather.currentconditions"
1088
+ params = {
1089
+ "UUID": seeded_uuid(f"{case_name}-weather-custom-empty"),
1090
+ "WFWeatherCustomLocation": {},
1091
+ }
1092
+ expected = "Location parameter WFWeatherCustomLocation is not a token attachment"
1093
+ elif pattern == 5:
1094
+ ident = "is.workflow.actions.weather.currentconditions"
1095
+ params = {
1096
+ "UUID": seeded_uuid(f"{case_name}-weather-custom-empty-token"),
1097
+ "WFWeatherCustomLocation": {
1098
+ "WFSerializationType": "WFTextTokenAttachment",
1099
+ "Value": {},
1100
+ },
1101
+ }
1102
+ expected = "Location parameter WFWeatherCustomLocation is empty"
1103
+ elif pattern == 6:
1104
+ ident = "is.workflow.actions.weather.currentconditions"
1105
+ params = {
1106
+ "UUID": seeded_uuid(f"{case_name}-weather-custom-unknown"),
1107
+ "WFWeatherCustomLocation": attachment_action_output(missing_uuid, "Current Location"),
1108
+ }
1109
+ expected = "Location parameter WFWeatherCustomLocation references unknown OutputUUID"
1110
+ elif pattern == 7:
1111
+ ident = "is.workflow.actions.weather.forecast"
1112
+ params = {
1113
+ "UUID": seeded_uuid(f"{case_name}-weather-location-empty"),
1114
+ "WFWeatherForecastType": "Daily",
1115
+ "WFWeatherLocation": {},
1116
+ }
1117
+ expected = "Location parameter WFWeatherLocation is not a token attachment"
1118
+ elif pattern == 8:
1119
+ actions.append(
1120
+ {
1121
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1122
+ "WFWorkflowActionParameters": {
1123
+ "WFVariableName": var_name,
1124
+ "WFInput": attachment_action_output(loc_uuid, "Current Location"),
1125
+ },
1126
+ }
1127
+ )
1128
+ ident = "is.workflow.actions.weather.forecast"
1129
+ params = {
1130
+ "UUID": seeded_uuid(f"{case_name}-weather-location-token"),
1131
+ "WFWeatherForecastType": "Daily",
1132
+ "WFWeatherLocation": token_string_variable(var_name),
1133
+ }
1134
+ expected = "should use WFTextTokenAttachment"
1135
+ else:
1136
+ ident = "is.workflow.actions.location"
1137
+ params = {
1138
+ "UUID": seeded_uuid(f"{case_name}-loc-empty-string"),
1139
+ "WFLocation": " ",
1140
+ }
1141
+ expected = "Location parameter WFLocation is empty"
1142
+
1143
+ actions.append({"WFWorkflowActionIdentifier": ident, "WFWorkflowActionParameters": params})
1144
+ return root_plist(case_name, actions), expected
1145
+
1146
+
1147
+ def make_set_name_valid(case_name: str, idx: int) -> dict:
1148
+ prompt = f"Set Name valid case {idx}"
1149
+ actions = base_actions(case_name, prompt)
1150
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1151
+ set_name_uuid = seeded_uuid(f"{case_name}-set-name")
1152
+ var_name = f"New Name {idx}"
1153
+
1154
+ actions.append(
1155
+ {
1156
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
1157
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid, "WFTextActionText": "file payload"},
1158
+ }
1159
+ )
1160
+
1161
+ if idx % 2 == 0:
1162
+ new_name_param = "Test.txt"
1163
+ else:
1164
+ actions.append(
1165
+ {
1166
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1167
+ "WFWorkflowActionParameters": {
1168
+ "WFVariableName": var_name,
1169
+ "WFInput": {"WFSerializationType": "WFTextTokenString", "Value": {"string": "Test.txt", "attachmentsByRange": {}}},
1170
+ },
1171
+ }
1172
+ )
1173
+ new_name_param = token_string_variable(var_name)
1174
+
1175
+ actions.append(
1176
+ {
1177
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setitemname",
1178
+ "WFWorkflowActionParameters": {
1179
+ "UUID": set_name_uuid,
1180
+ "WFInput": attachment_action_output(file_src_uuid, "Text"),
1181
+ "WFName": new_name_param,
1182
+ },
1183
+ }
1184
+ )
1185
+ return root_plist(case_name, actions)
1186
+
1187
+
1188
+ def make_set_name_invalid(case_name: str, idx: int) -> tuple[dict, str]:
1189
+ prompt = f"Set Name invalid case {idx}"
1190
+ actions = base_actions(case_name, prompt)
1191
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1192
+ pattern = idx % 6
1193
+ set_name_uuid = seeded_uuid(f"{case_name}-set-name")
1194
+
1195
+ actions.append(
1196
+ {
1197
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
1198
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid, "WFTextActionText": "file payload"},
1199
+ }
1200
+ )
1201
+
1202
+ params = {"UUID": set_name_uuid}
1203
+ expected = "Set Name"
1204
+ if pattern == 0:
1205
+ params["WFName"] = "Test.txt"
1206
+ expected = "Set Name missing WFInput"
1207
+ elif pattern == 1:
1208
+ params["WFInput"] = {}
1209
+ params["WFName"] = "Test.txt"
1210
+ expected = "Set Name WFInput is not a token attachment"
1211
+ elif pattern == 2:
1212
+ params["WFInput"] = attachment_action_output(file_src_uuid, "Text")
1213
+ expected = "Set Name missing WFName"
1214
+ elif pattern == 3:
1215
+ params["WFInput"] = attachment_action_output(file_src_uuid, "Text")
1216
+ params["WFName"] = " "
1217
+ expected = "Set Name missing WFName"
1218
+ elif pattern == 4:
1219
+ params["WFInput"] = {
1220
+ "WFSerializationType": "WFTextTokenAttachment",
1221
+ "Value": {},
1222
+ }
1223
+ params["WFName"] = "Test.txt"
1224
+ expected = "Set Name missing WFInput"
1225
+ else:
1226
+ params["WFInput"] = attachment_action_output("00000000-0000-4000-8000-000000000001", "File")
1227
+ params["WFName"] = "Renamed.txt"
1228
+ expected = "Set Name WFInput references unknown OutputUUID"
1229
+
1230
+ actions.append(
1231
+ {
1232
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setitemname",
1233
+ "WFWorkflowActionParameters": params,
1234
+ }
1235
+ )
1236
+
1237
+ return root_plist(case_name, actions), expected
1238
+
1239
+
1240
+ def make_set_name_delete_variable_valid(case_name: str) -> dict:
1241
+ prompt = "Set Name followed by Delete File variable reuse valid case"
1242
+ actions = base_actions(case_name, prompt)
1243
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1244
+ set_name_uuid = seeded_uuid(f"{case_name}-set-name")
1245
+
1246
+ actions.extend(
1247
+ [
1248
+ {
1249
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.select",
1250
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid},
1251
+ },
1252
+ {
1253
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1254
+ "WFWorkflowActionParameters": {
1255
+ "WFVariableName": "Original File",
1256
+ "WFInput": attachment_action_output(file_src_uuid, "File"),
1257
+ },
1258
+ },
1259
+ {
1260
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setitemname",
1261
+ "WFWorkflowActionParameters": {
1262
+ "UUID": set_name_uuid,
1263
+ "WFInput": attachment_variable("Original File"),
1264
+ "WFName": "Renamed.txt",
1265
+ },
1266
+ },
1267
+ {
1268
+ "WFWorkflowActionIdentifier": "is.workflow.actions.documentpicker.save",
1269
+ "WFWorkflowActionParameters": {
1270
+ "UUID": seeded_uuid(f"{case_name}-save-renamed"),
1271
+ "WFInput": attachment_action_output(set_name_uuid, "Renamed Item"),
1272
+ },
1273
+ },
1274
+ {
1275
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.delete",
1276
+ "WFWorkflowActionParameters": {
1277
+ "UUID": seeded_uuid(f"{case_name}-delete-original"),
1278
+ "WFInput": attachment_variable("Original File"),
1279
+ },
1280
+ },
1281
+ ]
1282
+ )
1283
+ return root_plist(case_name, actions)
1284
+
1285
+
1286
+ def make_set_name_delete_by_path_valid(case_name: str) -> dict:
1287
+ prompt = "Set Name followed by Delete File using captured original path valid case"
1288
+ actions = base_actions(case_name, prompt)
1289
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1290
+ path_uuid = seeded_uuid(f"{case_name}-path")
1291
+ set_name_uuid = seeded_uuid(f"{case_name}-set-name")
1292
+
1293
+ actions.extend(
1294
+ [
1295
+ {
1296
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.select",
1297
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid},
1298
+ },
1299
+ {
1300
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.files",
1301
+ "WFWorkflowActionParameters": {
1302
+ "UUID": path_uuid,
1303
+ "WFContentItemPropertyName": "File Path",
1304
+ "WFInput": attachment_action_output(file_src_uuid, "File"),
1305
+ },
1306
+ },
1307
+ {
1308
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setitemname",
1309
+ "WFWorkflowActionParameters": {
1310
+ "UUID": set_name_uuid,
1311
+ "WFInput": attachment_action_output(file_src_uuid, "File"),
1312
+ "WFName": "Renamed.txt",
1313
+ },
1314
+ },
1315
+ {
1316
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.delete",
1317
+ "WFWorkflowActionParameters": {
1318
+ "UUID": seeded_uuid(f"{case_name}-delete-by-path"),
1319
+ "WFInput": attachment_action_output(path_uuid, "File Path"),
1320
+ },
1321
+ },
1322
+ ]
1323
+ )
1324
+ return root_plist(case_name, actions)
1325
+
1326
+
1327
+ def make_rename_file_share_invalid(case_name: str) -> tuple[dict, str]:
1328
+ prompt = "Rename File used before Share invalid case"
1329
+ actions = base_actions(case_name, prompt)
1330
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1331
+ rename_uuid = seeded_uuid(f"{case_name}-rename")
1332
+
1333
+ actions.extend(
1334
+ [
1335
+ {
1336
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.select",
1337
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid},
1338
+ },
1339
+ {
1340
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1341
+ "WFWorkflowActionParameters": {
1342
+ "WFVariableName": "Original File",
1343
+ "WFInput": attachment_action_output(file_src_uuid, "File"),
1344
+ },
1345
+ },
1346
+ {
1347
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.rename",
1348
+ "WFWorkflowActionParameters": {
1349
+ "UUID": rename_uuid,
1350
+ "WFFile": attachment_variable("Original File"),
1351
+ "WFNewFilename": "Renamed.txt",
1352
+ },
1353
+ },
1354
+ {
1355
+ "WFWorkflowActionIdentifier": "is.workflow.actions.share",
1356
+ "WFWorkflowActionParameters": {
1357
+ "UUID": seeded_uuid(f"{case_name}-share-renamed"),
1358
+ "WFInput": attachment_action_output(rename_uuid, "Renamed File"),
1359
+ },
1360
+ },
1361
+ ]
1362
+ )
1363
+ return root_plist(case_name, actions), "Share consumes Rename File output"
1364
+
1365
+
1366
+ def make_rename_file_delete_original_invalid(case_name: str) -> tuple[dict, str]:
1367
+ prompt = "Rename File followed by Delete File original variable invalid case"
1368
+ actions = base_actions(case_name, prompt)
1369
+ file_src_uuid = seeded_uuid(f"{case_name}-file-src")
1370
+ actions.extend(
1371
+ [
1372
+ {
1373
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.select",
1374
+ "WFWorkflowActionParameters": {"UUID": file_src_uuid},
1375
+ },
1376
+ {
1377
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1378
+ "WFWorkflowActionParameters": {
1379
+ "WFVariableName": "Original File",
1380
+ "WFInput": attachment_action_output(file_src_uuid, "File"),
1381
+ },
1382
+ },
1383
+ {
1384
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.rename",
1385
+ "WFWorkflowActionParameters": {
1386
+ "UUID": seeded_uuid(f"{case_name}-rename"),
1387
+ "WFFile": attachment_variable("Original File"),
1388
+ "WFNewFilename": "Renamed.txt",
1389
+ },
1390
+ },
1391
+ {
1392
+ "WFWorkflowActionIdentifier": "is.workflow.actions.file.delete",
1393
+ "WFWorkflowActionParameters": {
1394
+ "UUID": seeded_uuid(f"{case_name}-delete-original"),
1395
+ "WFInput": attachment_variable("Original File"),
1396
+ },
1397
+ },
1398
+ ]
1399
+ )
1400
+ return root_plist(case_name, actions), "Delete File reuses variable"
1401
+
1402
+
1403
+ def make_health_find_detail_valid(case_name: str, idx: int, health_type: str) -> dict:
1404
+ prompt = f"HealthKit find/detail valid case {idx}"
1405
+ actions = base_actions(case_name, prompt)
1406
+ find_uuid = seeded_uuid(f"{case_name}-find-health")
1407
+ detail_uuid = seeded_uuid(f"{case_name}-detail-health")
1408
+ detail = HEALTH_SAMPLE_DETAILS[idx % len(HEALTH_SAMPLE_DETAILS)]
1409
+
1410
+ actions.append(
1411
+ {
1412
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1413
+ "WFWorkflowActionParameters": {
1414
+ "UUID": find_uuid,
1415
+ "WFContentItemFilter": health_filter_template(health_type),
1416
+ "WFContentItemLimitEnabled": False,
1417
+ },
1418
+ }
1419
+ )
1420
+ actions.append(
1421
+ {
1422
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.health.quantity",
1423
+ "WFWorkflowActionParameters": {
1424
+ "UUID": detail_uuid,
1425
+ "WFContentItemPropertyName": detail,
1426
+ "WFInput": attachment_action_output(find_uuid, "Health Samples"),
1427
+ },
1428
+ }
1429
+ )
1430
+ return root_plist(case_name, actions)
1431
+
1432
+
1433
+ def make_health_find_detail_via_variable_valid(case_name: str, health_type: str) -> dict:
1434
+ prompt = "HealthKit find/detail via named variable valid case"
1435
+ actions = base_actions(case_name, prompt)
1436
+ find_uuid = seeded_uuid(f"{case_name}-find-health")
1437
+ detail_uuid = seeded_uuid(f"{case_name}-detail-health")
1438
+ var_name = "Latest Health Samples"
1439
+
1440
+ actions.append(
1441
+ {
1442
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1443
+ "WFWorkflowActionParameters": {
1444
+ "UUID": find_uuid,
1445
+ "WFContentItemFilter": health_filter_template(health_type),
1446
+ "WFContentItemLimitEnabled": False,
1447
+ },
1448
+ }
1449
+ )
1450
+ actions.append(
1451
+ {
1452
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1453
+ "WFWorkflowActionParameters": {
1454
+ "WFVariableName": var_name,
1455
+ "WFInput": attachment_action_output(find_uuid, "Health Samples"),
1456
+ },
1457
+ }
1458
+ )
1459
+ actions.append(
1460
+ {
1461
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.health.quantity",
1462
+ "WFWorkflowActionParameters": {
1463
+ "UUID": detail_uuid,
1464
+ "WFContentItemPropertyName": "Value",
1465
+ "WFInput": attachment_variable(var_name),
1466
+ },
1467
+ }
1468
+ )
1469
+ return root_plist(case_name, actions)
1470
+
1471
+
1472
+ def make_health_log_quantity_valid(case_name: str, idx: int, health_type: str, unit: str) -> dict:
1473
+ prompt = f"HealthKit log quantity valid case {idx}"
1474
+ actions = base_actions(case_name, prompt)
1475
+ params = {
1476
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1477
+ "WFQuantitySampleType": health_type,
1478
+ "WFQuantitySampleQuantity": quantity_field("1", unit),
1479
+ "WFQuantitySampleDate": current_date_attachment(),
1480
+ }
1481
+ if idx % 11 == 0:
1482
+ params["WFQuantitySampleAdditionalQuantity"] = quantity_field(None, unit)
1483
+ actions.append(
1484
+ {
1485
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1486
+ "WFWorkflowActionParameters": params,
1487
+ }
1488
+ )
1489
+ return root_plist(case_name, actions)
1490
+
1491
+
1492
+ def make_health_log_quantity_variable_valid(case_name: str) -> dict:
1493
+ prompt = "HealthKit log quantity with variable magnitude valid case"
1494
+ actions = base_actions(case_name, prompt)
1495
+ number_uuid = seeded_uuid(f"{case_name}-number")
1496
+ actions.append(
1497
+ {
1498
+ "WFWorkflowActionIdentifier": "is.workflow.actions.number",
1499
+ "WFWorkflowActionParameters": {
1500
+ "UUID": number_uuid,
1501
+ "WFNumberActionNumber": "120",
1502
+ },
1503
+ }
1504
+ )
1505
+ actions.append(
1506
+ {
1507
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1508
+ "WFWorkflowActionParameters": {
1509
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1510
+ "WFQuantitySampleType": "Caffeine",
1511
+ "WFQuantitySampleQuantity": quantity_field(
1512
+ token_string_action_output(number_uuid, "Number"), "g"
1513
+ ),
1514
+ "WFQuantitySampleDate": current_date_attachment(),
1515
+ },
1516
+ }
1517
+ )
1518
+ return root_plist(case_name, actions)
1519
+
1520
+
1521
+ def make_health_log_category_valid(case_name: str, idx: int, health_type: str, value: str) -> dict:
1522
+ prompt = f"HealthKit log category valid case {idx}"
1523
+ actions = base_actions(case_name, prompt)
1524
+ actions.append(
1525
+ {
1526
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1527
+ "WFWorkflowActionParameters": {
1528
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1529
+ "WFQuantitySampleType": health_type,
1530
+ "WFCategorySampleEnumeration": value,
1531
+ "WFQuantitySampleQuantity": quantity_field("10", "count"),
1532
+ "WFQuantitySampleAdditionalQuantity": quantity_field(None, "count"),
1533
+ "WFQuantitySampleDate": current_date_attachment(),
1534
+ "WFSampleEndDate": current_date_attachment(),
1535
+ },
1536
+ }
1537
+ )
1538
+ return root_plist(case_name, actions)
1539
+
1540
+
1541
+ def make_health_log_unit_valid(case_name: str, idx: int, unit: str) -> dict:
1542
+ prompt = f"HealthKit ActionKit unit valid case {idx}"
1543
+ actions = base_actions(case_name, prompt)
1544
+ actions.append(
1545
+ {
1546
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1547
+ "WFWorkflowActionParameters": {
1548
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1549
+ "WFQuantitySampleType": "Caffeine",
1550
+ "WFQuantitySampleQuantity": quantity_field("10", unit),
1551
+ "WFQuantitySampleAdditionalQuantity": quantity_field(None, unit),
1552
+ "WFQuantitySampleDate": current_date_attachment(),
1553
+ },
1554
+ }
1555
+ )
1556
+ return root_plist(case_name, actions)
1557
+
1558
+
1559
+ def make_health_find_invalid_type_label(case_name: str, label: str) -> dict:
1560
+ prompt = f"HealthKit invalid Find Health Samples label {label}"
1561
+ actions = base_actions(case_name, prompt)
1562
+ actions.append(
1563
+ {
1564
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1565
+ "WFWorkflowActionParameters": {
1566
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1567
+ "WFContentItemFilter": health_filter_template(label),
1568
+ },
1569
+ }
1570
+ )
1571
+ return root_plist(case_name, actions)
1572
+
1573
+
1574
+ def make_health_sleep_duration_divide_60_invalid(case_name: str) -> dict:
1575
+ prompt = "HealthKit invalid sleep duration conversion"
1576
+ actions = base_actions(case_name, prompt)
1577
+ number_uuid = seeded_uuid(f"{case_name}-sleep-total-number")
1578
+ divide_uuid = seeded_uuid(f"{case_name}-sleep-divide")
1579
+ round_uuid = seeded_uuid(f"{case_name}-sleep-round")
1580
+ actions.extend(
1581
+ [
1582
+ {
1583
+ "WFWorkflowActionIdentifier": "is.workflow.actions.number",
1584
+ "WFWorkflowActionParameters": {
1585
+ "UUID": number_uuid,
1586
+ "WFNumberActionNumber": 0,
1587
+ },
1588
+ },
1589
+ {
1590
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1591
+ "WFWorkflowActionParameters": {
1592
+ "UUID": seeded_uuid(f"{case_name}-set-sleep-total"),
1593
+ "WFVariableName": "Sleep Total",
1594
+ "WFInput": attachment_action_output(number_uuid, "Number"),
1595
+ },
1596
+ },
1597
+ {
1598
+ "WFWorkflowActionIdentifier": "is.workflow.actions.math",
1599
+ "WFWorkflowActionParameters": {
1600
+ "UUID": divide_uuid,
1601
+ "WFInput": attachment_variable("Sleep Total"),
1602
+ "WFMathOperation": "÷",
1603
+ "WFMathOperand": "60",
1604
+ },
1605
+ },
1606
+ {
1607
+ "WFWorkflowActionIdentifier": "is.workflow.actions.round",
1608
+ "WFWorkflowActionParameters": {
1609
+ "UUID": round_uuid,
1610
+ "WFInput": attachment_action_output(divide_uuid, "Calculation Result"),
1611
+ "WFRoundMode": "Normal",
1612
+ "WFRoundTo": "Tenths",
1613
+ },
1614
+ },
1615
+ {
1616
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1617
+ "WFWorkflowActionParameters": {
1618
+ "UUID": seeded_uuid(f"{case_name}-set-sleep-hours"),
1619
+ "WFVariableName": "Sleep Hours",
1620
+ "WFInput": attachment_action_output(round_uuid, "Rounded Number"),
1621
+ },
1622
+ },
1623
+ ]
1624
+ )
1625
+ return root_plist(case_name, actions)
1626
+
1627
+
1628
+ def make_health_distance_divide_1000_invalid(case_name: str) -> dict:
1629
+ prompt = "HealthKit invalid distance conversion"
1630
+ actions = base_actions(case_name, prompt)
1631
+ number_uuid = seeded_uuid(f"{case_name}-distance-total-number")
1632
+ actions.extend(
1633
+ [
1634
+ {
1635
+ "WFWorkflowActionIdentifier": "is.workflow.actions.number",
1636
+ "WFWorkflowActionParameters": {
1637
+ "UUID": number_uuid,
1638
+ "WFNumberActionNumber": 0,
1639
+ },
1640
+ },
1641
+ {
1642
+ "WFWorkflowActionIdentifier": "is.workflow.actions.setvariable",
1643
+ "WFWorkflowActionParameters": {
1644
+ "UUID": seeded_uuid(f"{case_name}-set-distance-total"),
1645
+ "WFVariableName": "Distance Total",
1646
+ "WFInput": attachment_action_output(number_uuid, "Number"),
1647
+ },
1648
+ },
1649
+ {
1650
+ "WFWorkflowActionIdentifier": "is.workflow.actions.math",
1651
+ "WFWorkflowActionParameters": {
1652
+ "UUID": seeded_uuid(f"{case_name}-distance-divide"),
1653
+ "WFInput": attachment_variable("Distance Total"),
1654
+ "WFMathOperation": "÷",
1655
+ "WFMathOperand": "1000",
1656
+ },
1657
+ },
1658
+ ]
1659
+ )
1660
+ return root_plist(case_name, actions)
1661
+
1662
+
1663
+ def make_health_workout_valid(case_name: str, idx: int, workout_type: str) -> dict:
1664
+ prompt = f"HealthKit workout valid case {idx}"
1665
+ actions = base_actions(case_name, prompt)
1666
+ actions.append(
1667
+ {
1668
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.workout.log",
1669
+ "WFWorkflowActionParameters": {
1670
+ "UUID": seeded_uuid(f"{case_name}-log-workout"),
1671
+ "WFWorkoutReadableActivityType": workout_type,
1672
+ "WFWorkoutDate": current_date_attachment(),
1673
+ "WFWorkoutDuration": quantity_field("30", "min"),
1674
+ "WFWorkoutCaloriesQuantity": quantity_field("100", "kcal"),
1675
+ "WFWorkoutDistanceQuantity": quantity_field("1000", "m"),
1676
+ },
1677
+ }
1678
+ )
1679
+ return root_plist(case_name, actions)
1680
+
1681
+
1682
+ def make_health_invalid(case_name: str, idx: int) -> tuple[dict, str]:
1683
+ prompt = f"HealthKit invalid case {idx}"
1684
+ actions = base_actions(case_name, prompt)
1685
+ pattern = idx % 14
1686
+ expected = "Health"
1687
+
1688
+ if pattern == 0:
1689
+ actions.append(
1690
+ {
1691
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1692
+ "WFWorkflowActionParameters": {
1693
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1694
+ "WFContentItemFilter": health_filter_template(None),
1695
+ },
1696
+ }
1697
+ )
1698
+ expected = "Type filter"
1699
+ elif pattern == 1:
1700
+ actions.append(
1701
+ {
1702
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1703
+ "WFWorkflowActionParameters": {
1704
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1705
+ },
1706
+ }
1707
+ )
1708
+ expected = "WFContentItemFilter"
1709
+ elif pattern == 2:
1710
+ find_uuid = seeded_uuid(f"{case_name}-find-health")
1711
+ actions.append(
1712
+ {
1713
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1714
+ "WFWorkflowActionParameters": {
1715
+ "UUID": find_uuid,
1716
+ "WFContentItemFilter": health_filter_template("Steps"),
1717
+ "WFContentItemLimitEnabled": False,
1718
+ },
1719
+ }
1720
+ )
1721
+ actions.append(
1722
+ {
1723
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.health.quantity",
1724
+ "WFWorkflowActionParameters": {
1725
+ "UUID": seeded_uuid(f"{case_name}-detail-health"),
1726
+ "WFInput": attachment_action_output(find_uuid, "Health Samples"),
1727
+ },
1728
+ }
1729
+ )
1730
+ expected = "WFContentItemPropertyName"
1731
+ elif pattern == 3:
1732
+ actions.append(
1733
+ {
1734
+ "WFWorkflowActionIdentifier": "is.workflow.actions.properties.health.quantity",
1735
+ "WFWorkflowActionParameters": {
1736
+ "UUID": seeded_uuid(f"{case_name}-detail-health"),
1737
+ "WFContentItemPropertyName": "Value",
1738
+ "WFInput": attachment_action_output(seeded_uuid(f"{case_name}-missing"), "Text"),
1739
+ },
1740
+ }
1741
+ )
1742
+ expected = "unknown OutputUUID"
1743
+ elif pattern == 4:
1744
+ actions.append(
1745
+ {
1746
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1747
+ "WFWorkflowActionParameters": {
1748
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1749
+ "WFQuantitySampleQuantity": quantity_field("1", "g"),
1750
+ },
1751
+ }
1752
+ )
1753
+ expected = "WFQuantitySampleType"
1754
+ elif pattern == 5:
1755
+ actions.append(
1756
+ {
1757
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1758
+ "WFWorkflowActionParameters": {
1759
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1760
+ "WFQuantitySampleType": "Caffeine",
1761
+ "WFQuantitySampleQuantity": quantity_field(None, "g"),
1762
+ },
1763
+ }
1764
+ )
1765
+ expected = "missing Magnitude"
1766
+ elif pattern == 6:
1767
+ actions.append(
1768
+ {
1769
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1770
+ "WFWorkflowActionParameters": {
1771
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1772
+ "WFQuantitySampleType": "Caffeine",
1773
+ "WFQuantitySampleQuantity": {
1774
+ "WFSerializationType": "WFTextTokenString",
1775
+ "Value": {"string": "120", "attachmentsByRange": {}},
1776
+ },
1777
+ },
1778
+ }
1779
+ )
1780
+ expected = "WFQuantityFieldValue"
1781
+ elif pattern == 7:
1782
+ actions.append(
1783
+ {
1784
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.workout.log",
1785
+ "WFWorkflowActionParameters": {
1786
+ "UUID": seeded_uuid(f"{case_name}-log-workout"),
1787
+ "WFWorkoutDuration": quantity_field("30", "min"),
1788
+ },
1789
+ }
1790
+ )
1791
+ expected = "WFWorkoutReadableActivityType"
1792
+ elif pattern == 8:
1793
+ actions.append(
1794
+ {
1795
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.workout.log",
1796
+ "WFWorkflowActionParameters": {
1797
+ "UUID": seeded_uuid(f"{case_name}-log-workout"),
1798
+ "WFWorkoutReadableActivityType": "Running",
1799
+ "WFWorkoutDuration": quantity_field(None, "min"),
1800
+ },
1801
+ }
1802
+ )
1803
+ expected = "missing Magnitude"
1804
+ elif pattern == 9:
1805
+ actions.append(
1806
+ {
1807
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1808
+ "WFWorkflowActionParameters": {
1809
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1810
+ "WFContentItemFilter": health_filter_template("Not A Health Quantity"),
1811
+ },
1812
+ }
1813
+ )
1814
+ expected = "unknown Type filter value"
1815
+ elif pattern == 10:
1816
+ actions.append(
1817
+ {
1818
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1819
+ "WFWorkflowActionParameters": {
1820
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1821
+ "WFContentItemFilter": malformed_health_value_filter_template("Step Count"),
1822
+ },
1823
+ }
1824
+ )
1825
+ expected = "Type filter"
1826
+ elif pattern == 11:
1827
+ actions.append(
1828
+ {
1829
+ "WFWorkflowActionIdentifier": "is.workflow.actions.filter.health.quantity",
1830
+ "WFWorkflowActionParameters": {
1831
+ "UUID": seeded_uuid(f"{case_name}-find-health"),
1832
+ "WFContentItemFilter": mixed_health_type_and_value_filter_template(),
1833
+ },
1834
+ }
1835
+ )
1836
+ expected = "Type filter"
1837
+ elif pattern == 12:
1838
+ actions.append(
1839
+ {
1840
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.quantity.log",
1841
+ "WFWorkflowActionParameters": {
1842
+ "UUID": seeded_uuid(f"{case_name}-log-health"),
1843
+ "WFQuantitySampleType": "Caffeine",
1844
+ "WFQuantitySampleQuantity": quantity_field("1", "parsecs"),
1845
+ },
1846
+ }
1847
+ )
1848
+ expected = "unknown Unit"
1849
+ else:
1850
+ actions.append(
1851
+ {
1852
+ "WFWorkflowActionIdentifier": "is.workflow.actions.health.workout.log",
1853
+ "WFWorkflowActionParameters": {
1854
+ "UUID": seeded_uuid(f"{case_name}-log-workout"),
1855
+ "WFWorkoutReadableActivityType": "Moonwalking",
1856
+ "WFWorkoutDuration": quantity_field("30", "min"),
1857
+ },
1858
+ }
1859
+ )
1860
+ expected = "unknown WFWorkoutReadableActivityType"
1861
+
1862
+ return root_plist(case_name, actions), expected
1863
+
1864
+
1865
+ def build_cases() -> list[Case]:
1866
+ cases: list[Case] = []
1867
+
1868
+ cases.append(Case("url-schemes", "ZZ-Shortcuts-URL-Valid", make_shortcuts_url_valid("ZZ-Shortcuts-URL-Valid"), True))
1869
+ cases.append(
1870
+ Case(
1871
+ "url-schemes",
1872
+ "ZZ-Shortcuts-URL-Unsupported-Invalid",
1873
+ make_shortcuts_url_invalid("ZZ-Shortcuts-URL-Unsupported-Invalid"),
1874
+ False,
1875
+ "Unsupported Apple-documented Shortcuts URL route",
1876
+ )
1877
+ )
1878
+ cases.append(
1879
+ Case(
1880
+ "javascript-webpage",
1881
+ "ZZ-JavaScript-Webpage-Valid",
1882
+ make_javascript_webpage_valid("ZZ-JavaScript-Webpage-Valid"),
1883
+ True,
1884
+ )
1885
+ )
1886
+ cases.append(
1887
+ Case(
1888
+ "javascript-webpage",
1889
+ "ZZ-JavaScript-Webpage-Invalid",
1890
+ make_javascript_webpage_invalid("ZZ-JavaScript-Webpage-Invalid"),
1891
+ False,
1892
+ "requires ActionExtension",
1893
+ )
1894
+ )
1895
+ cases.append(
1896
+ Case(
1897
+ "send-message",
1898
+ "ZZ-SendMessage-Single-Type-Valid",
1899
+ make_send_message_single_type_valid("ZZ-SendMessage-Single-Type-Valid"),
1900
+ True,
1901
+ )
1902
+ )
1903
+ for name, maker in [
1904
+ ("ZZ-SendMessage-One-Append-Invalid", make_send_message_one_append_invalid),
1905
+ ("ZZ-SendMessage-ActionOutput-Invalid", make_send_message_action_output_invalid),
1906
+ ("ZZ-SendMessage-Attachment-Invalid", make_send_message_attachment_invalid),
1907
+ ]:
1908
+ plist, expected = maker(name)
1909
+ cases.append(Case("send-message", name, plist, False, expected))
1910
+ cases.append(
1911
+ Case(
1912
+ "calendar-filters",
1913
+ "ZZ-Calendar-Next-Event-Filter-Valid",
1914
+ make_calendar_next_event_filter_valid("ZZ-Calendar-Next-Event-Filter-Valid"),
1915
+ True,
1916
+ )
1917
+ )
1918
+ plist, expected = make_calendar_next_event_filter_invalid("ZZ-Calendar-Next-Event-Filter-Invalid")
1919
+ cases.append(Case("calendar-filters", "ZZ-Calendar-Next-Event-Filter-Invalid", plist, False, expected))
1920
+ cases.append(
1921
+ Case(
1922
+ "date-delta",
1923
+ "ZZ-Get-Time-Between-Dates-Valid",
1924
+ make_get_time_between_dates_valid("ZZ-Get-Time-Between-Dates-Valid"),
1925
+ True,
1926
+ )
1927
+ )
1928
+ for name, maker in [
1929
+ ("ZZ-Get-Time-Between-Dates-CurrentDate-Invalid", make_get_time_between_dates_current_date_invalid),
1930
+ ("ZZ-Get-Time-Between-Dates-Attachment-Invalid", make_get_time_between_dates_attachment_invalid),
1931
+ ("ZZ-Text-Token-Placeholder-Invalid", make_text_token_placeholder_invalid),
1932
+ ]:
1933
+ plist, expected = maker(name)
1934
+ cases.append(Case("date-delta", name, plist, False, expected))
1935
+
1936
+ # 43 weather cases (21 valid + 22 invalid)
1937
+ for idx in range(20):
1938
+ case_name = f"ZZ-Weather-Valid-{idx + 1:02d}"
1939
+ cases.append(Case("weather", case_name, make_weather_valid(case_name, idx), True))
1940
+ cases.append(
1941
+ Case(
1942
+ "weather",
1943
+ "ZZ-Weather-Sun-Times-Valid",
1944
+ make_weather_sun_times_valid("ZZ-Weather-Sun-Times-Valid"),
1945
+ True,
1946
+ )
1947
+ )
1948
+ for idx in range(22):
1949
+ case_name = f"ZZ-Weather-Invalid-{idx + 1:02d}"
1950
+ plist, expected = make_weather_invalid(case_name, idx)
1951
+ cases.append(Case("weather", case_name, plist, False, expected))
1952
+
1953
+ # 40 location cases (20 valid + 20 invalid)
1954
+ for idx in range(20):
1955
+ case_name = f"ZZ-Location-Valid-{idx + 1:02d}"
1956
+ cases.append(Case("location", case_name, make_location_valid(case_name, idx), True))
1957
+ for idx in range(20):
1958
+ case_name = f"ZZ-Location-Invalid-{idx + 1:02d}"
1959
+ plist, expected = make_location_invalid(case_name, idx)
1960
+ cases.append(Case("location", case_name, plist, False, expected))
1961
+
1962
+ # 16 Set Name/Rename File cases (8 valid + 8 invalid)
1963
+ for idx in range(6):
1964
+ case_name = f"ZZ-SetName-Valid-{idx + 1:02d}"
1965
+ cases.append(Case("set-name", case_name, make_set_name_valid(case_name, idx), True))
1966
+ cases.append(
1967
+ Case(
1968
+ "set-name",
1969
+ "ZZ-SetName-Delete-Variable-Valid",
1970
+ make_set_name_delete_variable_valid("ZZ-SetName-Delete-Variable-Valid"),
1971
+ True,
1972
+ )
1973
+ )
1974
+ cases.append(
1975
+ Case(
1976
+ "set-name",
1977
+ "ZZ-SetName-Delete-By-Path-Valid",
1978
+ make_set_name_delete_by_path_valid("ZZ-SetName-Delete-By-Path-Valid"),
1979
+ True,
1980
+ )
1981
+ )
1982
+ for idx in range(6):
1983
+ case_name = f"ZZ-SetName-Invalid-{idx + 1:02d}"
1984
+ plist, expected = make_set_name_invalid(case_name, idx)
1985
+ cases.append(Case("set-name", case_name, plist, False, expected))
1986
+ for name, maker in [
1987
+ ("ZZ-RenameFile-Share-Invalid", make_rename_file_share_invalid),
1988
+ ("ZZ-RenameFile-Delete-Original-Invalid", make_rename_file_delete_original_invalid),
1989
+ ]:
1990
+ plist, expected = maker(name)
1991
+ cases.append(Case("set-name", name, plist, False, expected))
1992
+
1993
+ health = load_healthkit_reference()
1994
+ validate_healthkit_reference_labels(health)
1995
+ quantity_types = health.get("quantity_types", [])
1996
+ category_types = health.get("category_types", [])
1997
+ category_values = health.get("category_values", {})
1998
+ quantity_units = health.get("quantity_units", [])
1999
+ workout_types = health.get("workout_activity_types", [])
2000
+ enum_to_category_label: dict[str, str] = {}
2001
+ for row in category_types:
2002
+ enum_name = row.get("unit_and_aggregation")
2003
+ label = row.get("shortcut_label_guess") or row.get("sdk_suffix")
2004
+ if isinstance(enum_name, str) and isinstance(label, str) and enum_name.startswith("HKCategoryValue"):
2005
+ enum_to_category_label.setdefault(enum_name, label)
2006
+ # SDK comments and enum names are not always one-to-one; these aliases keep
2007
+ # enum value coverage tied to known Shortcuts category type labels.
2008
+ enum_to_category_label.setdefault("HKCategoryValueAudioExposureEvent", "Environmental Audio Exposure Event")
2009
+ enum_to_category_label.setdefault("HKCategoryValueMenstrualFlow", "Menstrual Flow")
2010
+
2011
+ # Find Health Samples + Get Details: all SDK-known quantity types.
2012
+ for idx, row in enumerate(quantity_types):
2013
+ label = health_find_sample_label(row) or f"Quantity {idx}"
2014
+ case_name = f"ZZ-Health-Find-Detail-Valid-{idx + 1:03d}"
2015
+ cases.append(Case("healthkit", case_name, make_health_find_detail_valid(case_name, idx, label), True))
2016
+
2017
+ # Find Health Samples also supports category types such as Sleep.
2018
+ for idx, row in enumerate(category_types):
2019
+ label = health_find_sample_label(row) or f"Category {idx}"
2020
+ case_name = f"ZZ-Health-Find-Detail-Category-Valid-{idx + 1:03d}"
2021
+ cases.append(Case("healthkit", case_name, make_health_find_detail_valid(case_name, idx, label), True))
2022
+
2023
+ if quantity_types:
2024
+ case_name = "ZZ-Health-Find-Detail-Variable-Valid"
2025
+ label = health_find_sample_label(quantity_types[0]) or "Steps"
2026
+ cases.append(
2027
+ Case(
2028
+ "healthkit",
2029
+ case_name,
2030
+ make_health_find_detail_via_variable_valid(case_name, label),
2031
+ True,
2032
+ )
2033
+ )
2034
+
2035
+ # Log Health Sample quantities: every SDK-known quantity type, with each
2036
+ # type's unit hint from HealthKit headers.
2037
+ for idx, row in enumerate(quantity_types):
2038
+ label = row.get("shortcut_label_guess") or row.get("sdk_suffix") or f"Quantity {idx}"
2039
+ case_name = f"ZZ-Health-Log-Quantity-Valid-{idx + 1:03d}"
2040
+ cases.append(
2041
+ Case(
2042
+ "healthkit",
2043
+ case_name,
2044
+ make_health_log_quantity_valid(case_name, idx, label, health_unit_hint(row)),
2045
+ True,
2046
+ )
2047
+ )
2048
+
2049
+ case_name = "ZZ-Health-Log-Quantity-Variable-Valid"
2050
+ cases.append(Case("healthkit", case_name, make_health_log_quantity_variable_valid(case_name), True))
2051
+
2052
+ # Log Health Sample units: every unit string exposed by ActionKit's local
2053
+ # WFHealthKitConstants.plist.
2054
+ for idx, row in enumerate(quantity_units):
2055
+ unit = row.get("unit") or f"unit-{idx}"
2056
+ case_name = f"ZZ-Health-Log-Unit-Valid-{idx + 1:03d}"
2057
+ cases.append(
2058
+ Case(
2059
+ "healthkit",
2060
+ case_name,
2061
+ make_health_log_unit_valid(case_name, idx, unit),
2062
+ True,
2063
+ )
2064
+ )
2065
+
2066
+ # Log Health Sample categories: every SDK-known category type, plus every
2067
+ # explicit category value enum exposed by HealthKit. Current iOS exports
2068
+ # category values with WFCategorySampleEnumeration plus the same count-based
2069
+ # quantity scaffold that quantity logs use.
2070
+ for idx, row in enumerate(category_types):
2071
+ label = row.get("shortcut_label_guess") or row.get("sdk_suffix") or f"Category {idx}"
2072
+ case_name = f"ZZ-Health-Log-Category-Valid-{idx + 1:03d}"
2073
+ cases.append(
2074
+ Case("healthkit", case_name, make_health_log_category_valid(case_name, idx, label, "Present"), True)
2075
+ )
2076
+
2077
+ enum_idx = 0
2078
+ for enum_name in sorted(category_values):
2079
+ for value_row in category_values.get(enum_name, []):
2080
+ label = enum_to_category_label.get(enum_name)
2081
+ if not label:
2082
+ label = category_types[0].get("shortcut_label_guess") if category_types else "Bloating"
2083
+ value = value_row.get("shortcut_label_guess") or value_row.get("symbol") or "Present"
2084
+ case_name = f"ZZ-Health-Log-Category-Value-Valid-{enum_idx + 1:03d}"
2085
+ cases.append(
2086
+ Case(
2087
+ "healthkit",
2088
+ case_name,
2089
+ make_health_log_category_valid(case_name, enum_idx, label, value),
2090
+ True,
2091
+ )
2092
+ )
2093
+ enum_idx += 1
2094
+
2095
+ # Log Workout: every SDK-known workout activity type.
2096
+ for idx, row in enumerate(workout_types):
2097
+ label = row.get("shortcut_label_guess") or row.get("symbol") or f"Workout {idx}"
2098
+ case_name = f"ZZ-Health-Workout-Valid-{idx + 1:03d}"
2099
+ cases.append(Case("healthkit", case_name, make_health_workout_valid(case_name, idx, label), True))
2100
+
2101
+ for idx in range(24):
2102
+ case_name = f"ZZ-Health-Invalid-{idx + 1:02d}"
2103
+ plist, expected = make_health_invalid(case_name, idx)
2104
+ cases.append(Case("healthkit", case_name, plist, False, expected))
2105
+
2106
+ stale_find_labels = [
2107
+ "Sleep Analysis",
2108
+ "Active Energy",
2109
+ "Active Energy Burned",
2110
+ "Apple Exercise Time",
2111
+ "Exercise Time",
2112
+ ]
2113
+ for idx, label in enumerate(stale_find_labels):
2114
+ case_name = f"ZZ-Health-Find-Stale-Label-Invalid-{idx + 1:02d}"
2115
+ cases.append(
2116
+ Case(
2117
+ "healthkit",
2118
+ case_name,
2119
+ make_health_find_invalid_type_label(case_name, label),
2120
+ False,
2121
+ "unknown Type filter value",
2122
+ )
2123
+ )
2124
+
2125
+ stale_log_labels = [
2126
+ "Active Energy",
2127
+ "Active Energy Burned",
2128
+ ]
2129
+ for idx, label in enumerate(stale_log_labels):
2130
+ case_name = f"ZZ-Health-Log-Stale-Label-Invalid-{idx + 1:02d}"
2131
+ cases.append(
2132
+ Case(
2133
+ "healthkit",
2134
+ case_name,
2135
+ make_health_log_quantity_valid(case_name, idx, label, "kcal"),
2136
+ False,
2137
+ "unknown WFQuantitySampleType",
2138
+ )
2139
+ )
2140
+
2141
+ case_name = "ZZ-Health-Sleep-Duration-Divide-60-Invalid"
2142
+ cases.append(
2143
+ Case(
2144
+ "healthkit",
2145
+ case_name,
2146
+ make_health_sleep_duration_divide_60_invalid(case_name),
2147
+ False,
2148
+ "Sleep duration math divides by 60",
2149
+ )
2150
+ )
2151
+
2152
+ case_name = "ZZ-Health-Distance-Divide-1000-Invalid"
2153
+ cases.append(
2154
+ Case(
2155
+ "healthkit",
2156
+ case_name,
2157
+ make_health_distance_divide_1000_invalid(case_name),
2158
+ False,
2159
+ "Health distance math divides",
2160
+ )
2161
+ )
2162
+
2163
+ return cases
2164
+
2165
+
2166
+ def write_fixture(path: Path, plist: dict) -> None:
2167
+ path.parent.mkdir(parents=True, exist_ok=True)
2168
+ path.write_bytes(plistlib.dumps(plist, fmt=plistlib.FMT_XML, sort_keys=False))
2169
+
2170
+
2171
+ def run_suite(cases: list[Case], fixture_dir: Optional[Path]) -> int:
2172
+ skill_dir = Path(__file__).resolve().parents[1]
2173
+ # This suite intentionally mixes macOS and iOS-only fixtures, including
2174
+ # HealthKit. Use all packaged platform snapshots so target-platform gating
2175
+ # does not hide the schemas this regression suite is meant to exercise.
2176
+ allowed_ids = load_allowed_ids(skill_dir, target_platform=None)
2177
+ allowed_glyph_ids, allowed_icon_colors = load_icon_metadata(skill_dir)
2178
+
2179
+ per_category: dict[str, dict[str, int]] = {}
2180
+ failures: list[str] = []
2181
+
2182
+ for case in cases:
2183
+ per_category.setdefault(
2184
+ case.category, {"total": 0, "expected_valid": 0, "expected_invalid": 0, "passed": 0}
2185
+ )
2186
+ stats = per_category[case.category]
2187
+ stats["total"] += 1
2188
+ stats["expected_valid" if case.expect_valid else "expected_invalid"] += 1
2189
+
2190
+ if fixture_dir is not None:
2191
+ write_fixture(fixture_dir / case.category / f"{case.name}.shortcut", case.plist)
2192
+
2193
+ errors, _ = validate(case.plist, allowed_ids, allowed_glyph_ids, allowed_icon_colors)
2194
+ ok = False
2195
+ if case.expect_valid:
2196
+ ok = len(errors) == 0
2197
+ else:
2198
+ if errors:
2199
+ if case.expected_error_substring:
2200
+ ok = any(case.expected_error_substring in err for err in errors)
2201
+ else:
2202
+ ok = True
2203
+
2204
+ if ok:
2205
+ stats["passed"] += 1
2206
+ continue
2207
+
2208
+ preview = " | ".join(errors[:3]) if errors else "NO ERRORS"
2209
+ failures.append(
2210
+ f"{case.category}/{case.name} expected "
2211
+ f"{'valid' if case.expect_valid else 'invalid'} but got {'valid' if not errors else 'invalid'} :: {preview}"
2212
+ )
2213
+
2214
+ print("Regression summary:")
2215
+ for category in sorted(per_category.keys()):
2216
+ stats = per_category[category]
2217
+ print(
2218
+ f"- {category}: {stats['passed']}/{stats['total']} passed "
2219
+ f"(valid={stats['expected_valid']}, invalid={stats['expected_invalid']})"
2220
+ )
2221
+
2222
+ if failures:
2223
+ print("\nFailures:")
2224
+ for item in failures:
2225
+ print(f"- {item}")
2226
+ return 1
2227
+
2228
+ print("\nAll regression cases matched expectations.")
2229
+ return 0
2230
+
2231
+
2232
+ def main() -> int:
2233
+ parser = argparse.ArgumentParser(
2234
+ description="Run weather/location wiring regression tests for generate-shortcuts-skill."
2235
+ )
2236
+ parser.add_argument(
2237
+ "--write-fixtures",
2238
+ help="Directory where generated .shortcut fixtures should be written",
2239
+ )
2240
+ args = parser.parse_args()
2241
+
2242
+ fixture_dir = Path(args.write_fixtures).expanduser().resolve() if args.write_fixtures else None
2243
+ return run_suite(build_cases(), fixture_dir)
2244
+
2245
+
2246
+ if __name__ == "__main__":
2247
+ raise SystemExit(main())