also looking at this
test(ios): record every deliberate deferral where something checks it
#119
1 file
+100
-0
| @@ -233,6 +233,106 @@ test "every action the page can call is one the spec handles" { | ||
| 233 | 233 | try testing.expect(checked >= 50); |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | /// Every action deliberately left with the Swift shim, and why. | |
| 237 | /// | |
| 238 | /// This exists because the reasons kept rotting. They lived in two places that | |
| 239 | /// nothing verified — a prose document and a module comment — and both drifted: | |
| 240 | /// `docs/ios-development.md` listed thirteen actions as permanently Swift while | |
| 241 | /// five of them had already been migrated, and | |
| 242 | /// `bridge_mobile_notifications.zig` explained that `scheduleNotification` | |
| 243 | /// could not be served for a day longer than that was true, which kept the | |
| 244 | /// action with the shim through several passes that each read the note and | |
| 245 | /// believed it. | |
| 246 | /// | |
| 247 | /// A list is only load-bearing if something fails when it is wrong. The test | |
| 248 | /// below asserts each entry is a real spec action *and* still unmigrated, so | |
| 249 | /// migrating one of these breaks the build until its row is deleted. That is | |
| 250 | /// the property the doc never had. | |
| 251 | /// | |
| 252 | /// Not exhaustive, and deliberately not: this records deferrals that have a | |
| 253 | /// *reason worth keeping*. An action absent from both this table and the Zig | |
| 254 | /// modules is simply not done yet, which is a different and honest state. | |
| 255 | const Deferral = struct { action: []const u8, reason: []const u8 }; | |
| 256 | ||
| 257 | const deliberate_deferrals = [_]Deferral{ | |
| 258 | // ARKit / SceneKit. `docs/ios-development.md` asked for this boundary to be | |
| 259 | // declared in `capability_registry.zig`; that registry is desktop-only — | |
| 260 | // 51 namespaces, no `mobile`, and neither `ios.zig` nor `ios_dispatch.zig` | |
| 261 | // reads it — so the boundary is recorded here instead, where the tooling | |
| 262 | // that counts unmigrated actions can see it. | |
| 263 | .{ .action = "startAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, | |
| 264 | .{ .action = "stopAR", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, | |
| 265 | .{ .action = "getARPlanes", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, | |
| 266 | .{ .action = "placeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, | |
| 267 | .{ .action = "removeARObject", .reason = "SceneKit node-graph glue; miserable and low-value through objc_msgSend" }, | |
| 268 | ||
| 269 | // StoreKit 2. Narrower than "no ObjC surface": StoreKit *1* is ObjC and | |
| 270 | // `bridge_iap.zig` already drives it from Zig on macOS, so this is a choice | |
| 271 | // about which StoreKit iOS targets, not an impossibility. | |
| 272 | .{ .action = "getProducts", .reason = "Product.PurchaseResult is Swift-only; StoreKit 1 would be a product decision" }, | |
| 273 | .{ .action = "purchase", .reason = "Product.PurchaseResult is Swift-only; StoreKit 1 would be a product decision" }, | |
| 274 | .{ .action = "restorePurchases", .reason = "Product.PurchaseResult is Swift-only; StoreKit 1 would be a product decision" }, | |
| 275 | ||
| 276 | .{ .action = "startLiveActivity", .reason = "ActivityKit is Swift-only; no ObjC class to reach" }, | |
| 277 | .{ .action = "updateLiveActivity", .reason = "ActivityKit is Swift-only; no ObjC class to reach" }, | |
| 278 | .{ .action = "endLiveActivity", .reason = "ActivityKit is Swift-only; no ObjC class to reach" }, | |
| 279 | ||
| 280 | .{ .action = "updateWidget", .reason = "WidgetCenter has no ObjC class; only a swiftself trampoline reaches it" }, | |
| 281 | .{ .action = "reloadWidgets", .reason = "WidgetCenter has no ObjC class; only a swiftself trampoline reaches it" }, | |
| 282 | ||
| 283 | // Structural, and checked again in the sweep that produced this table. | |
| 284 | .{ .action = "registerBackgroundTask", .reason = "BGTaskScheduler.register must run before launch finishes; a page call is late by construction" }, | |
| 285 | .{ .action = "getInitialURL", .reason = "DeepLinkManager is pure Swift with no @objc; the launch URL is not re-derivable" }, | |
| 286 | ||
| 287 | // The recorder is a CLLocationManager plus two files that | |
| 288 | // `CraftWebView.Coordinator.init` re-adopts at launch, so a Zig-owned stop | |
| 289 | // cannot stop a recording Swift has already restored into its own state. | |
| 290 | .{ .action = "startLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" }, | |
| 291 | .{ .action = "stopLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" }, | |
| 292 | .{ .action = "pauseLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" }, | |
| 293 | .{ .action = "resumeLocationRecording", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" }, | |
| 294 | .{ .action = "getLocationRecordingState", .reason = "Coordinator.init re-adopts recordings at launch; Zig cannot stop what Swift restored" }, | |
| 295 | ||
| 296 | // Not a capability gap: the spec's own success path has never executed, so | |
| 297 | // there is no working contract to port. See the tracking issue. | |
| 298 | .{ .action = "startVideoRecording", .reason = "the spec reads info[.originalImage] for a movie pick, so its success path never runs" }, | |
| 299 | }; | |
| 300 | ||
| 301 | test "every recorded deferral is real, and still a deferral" { | |
| 302 | // The anti-rot property. A deferral that has been migrated must fail here | |
| 303 | // rather than sit in a list telling the next reader not to bother. | |
| 304 | var spec = try collectCases(testing.allocator, dispatcherRegion()); | |
| 305 | defer spec.deinit(); | |
| 306 | ||
| 307 | var zig = try collectZigActions(testing.allocator); | |
| 308 | defer zig.deinit(); | |
| 309 | ||
| 310 | for (deliberate_deferrals) |d| { | |
| 311 | if (!spec.contains(d.action)) { | |
| 312 | std.debug.print( | |
| 313 | "deferral table names `{s}`, which the spec's dispatcher does not have — typo, or the action was renamed\n", | |
| 314 | .{d.action}, | |
| 315 | ); | |
| 316 | return error.DeferralNamesNoSuchAction; | |
| 317 | } | |
| 318 | if (zig.contains(d.action)) { | |
| 319 | std.debug.print( | |
| 320 | "deferral table still lists `{s}`, but Zig serves it now — delete the row, its reason is spent\n", | |
| 321 | .{d.action}, | |
| 322 | ); | |
| 323 | return error.DeferralAlreadyMigrated; | |
| 324 | } | |
| 325 | // A reason is the entire point of the row. | |
| 326 | try testing.expect(d.reason.len > 0); | |
| 327 | } | |
| 328 | ||
| 329 | // Non-vacuity: the loop above is satisfied by an empty table. | |
| 330 | try testing.expect(deliberate_deferrals.len >= 20); | |
| 331 | ||
| 332 | // And the table cannot claim more than remain unmigrated. | |
| 333 | try testing.expect(deliberate_deferrals.len <= max_not_yet_migrated); | |
| 334 | } | |
| 335 | ||
| 236 | 336 | test "nothing has been dropped on the way from Swift to Zig" { |
| 237 | 337 | // The migration guard. Every action the spec answers is either served by |
| 238 | 338 | // Zig or explicitly still owed, and the number still owed only goes down. |