ReviewOS

also looking at this

craft-native/craft

feat(ios): haptic, whose reason expired when ios_config landed

#130
Closed glennmichael123 wants to merge feat/ios-haptic-reason-expired into test/ios-reply-shape-conformance
3 files +236 -78
packages/zig/test/ios_conformance_test.zigmodified+107-0
Changes to packages/zig/test/ios_conformance_test.zig
@@ -643,6 +643,113 @@ test "the gate table has no entry for an action Zig does not serve" {
643643 }
644644}
645645
646/// The `.reason` text of every `.status = .unavailable` entry, by action.
647///
648/// The reason is what a reader trusts, so it is what has to be checked. A
649/// declaration can be resolved two ways here: `.reason = "..."` inline, and
650/// `.reason = some_const` naming a constant elsewhere in the file — both are
651/// in use, and a scan that handled only the first would silently skip the
652/// second rather than fail.
653fn collectUnavailableReasons(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) {
654 var map = std.StringHashMap([]const u8).init(allocator);
655 errdefer map.deinit();
656
657 for (zig_sources) |source| {
658 // Bounded to the manifest array, not the whole file: prose quotes
659 // `.status = .unavailable` — `bridge_mobile_haptics.zig`'s header does,
660 // describing the declaration it removed — and a scan that read comments
661 // would attribute that sentence to whichever entry preceded it.
662 const table_start = std.mem.indexOf(u8, source, "pub const capability_actions = [_]capabilities.ActionDecl{") orelse
663 continue;
664 const table_end = std.mem.indexOfPos(u8, source, table_start, "\n};") orelse continue;
665 const table = source[table_start..table_end];
666
667 var search: usize = 0;
668 while (std.mem.indexOfPos(u8, table, search, ".status = .unavailable")) |at| {
669 search = at + 1;
670
671 // The entry names its action as `A.some_const`; resolve it back
672 // through the `A` block to the string the spec spells.
673 const name_at = std.mem.lastIndexOf(u8, table[0..at], ".name = A.") orelse continue;
674 const const_start = name_at + ".name = A.".len;
675 var const_end = const_start;
676 while (const_end < table.len and (std.ascii.isAlphanumeric(table[const_end]) or
677 table[const_end] == '_')) : (const_end += 1)
678 {}
679
680 var needle_buf: [96]u8 = undefined;
681 const member = table[const_start..const_end];
682 if (member.len + 16 > needle_buf.len) continue;
683 const decl = try std.fmt.bufPrint(&needle_buf, "const {s} = \"", .{member});
684 const decl_at = std.mem.indexOf(u8, source, decl) orelse continue;
685 const value_start = decl_at + decl.len;
686 const value_end = std.mem.indexOfScalarPos(u8, source, value_start, '"') orelse continue;
687 const action = source[value_start..value_end];
688
689 // The reason, inline or by name.
690 const reason_at = std.mem.indexOfPos(u8, table, at, ".reason = ") orelse continue;
691 const reason_start = reason_at + ".reason = ".len;
692 if (table[reason_start] == '"') {
693 const reason_end = std.mem.indexOfScalarPos(u8, table, reason_start + 1, '"') orelse continue;
694 try map.put(action, table[reason_start + 1 .. reason_end]);
695 } else {
696 var ident_end = reason_start;
697 while (ident_end < table.len and (std.ascii.isAlphanumeric(table[ident_end]) or
698 table[ident_end] == '_')) : (ident_end += 1)
699 {}
700 var const_buf: [96]u8 = undefined;
701 const ident = table[reason_start..ident_end];
702 if (ident.len + 10 > const_buf.len) continue;
703 const const_decl = try std.fmt.bufPrint(&const_buf, "const {s} =", .{ident});
704 const body_at = std.mem.indexOf(u8, source, const_decl) orelse continue;
705 const body_end = std.mem.indexOfScalarPos(u8, source, body_at, ';') orelse continue;
706 // The whole initialiser, `++` concatenation included.
707 try map.put(action, source[body_at..body_end]);
708 }
709 }
710 }
711 return map;
712}
713
714test "no refusal blames a config flag Zig demonstrably reads" {
715 // The rot this exists to catch, in the exact shape it took. `haptic` was
716 // declared `.status = .unavailable` because `config.enableHaptics` "is not
717 // visible to Zig"; `gateFor`'s table said Zig reads that same flag and
718 // refuses the action when it is off. Both were written honestly, months
719 // apart, and nothing put them side by side — so the refusal outlived its
720 // reason and a working implementation sat unused two hundred lines below it.
721 //
722 // Deliberately narrower than "gated and unavailable", which is not a
723 // contradiction: `lockOrientation` is both, and its reason is about a root
724 // view controller rather than about reading a flag. What cannot stand is a
725 // refusal *naming* a flag the gate table proves Zig reads.
726 var gates = try collectZigGates(testing.allocator);
727 defer gates.deinit();
728
729 var reasons = try collectUnavailableReasons(testing.allocator);
730 defer reasons.deinit();
731
732 // Non-vacuity: the scan reads each manifest through two indirections — the
733 // `A.` constant and its declaration — and either could stop matching.
734 try testing.expect(reasons.count() >= 3);
735
736 var it = reasons.iterator();
737 while (it.next()) |entry| {
738 const flag = gates.get(entry.key_ptr.*) orelse continue;
739 if (std.mem.indexOf(u8, entry.value_ptr.*, flag) != null) {
740 std.debug.print(
741 "`{s}` is declared unavailable and its reason names `{s}`, which `gateFor` " ++
742 "maps it to.\n" ++
743 " The manifest says Zig cannot read that flag; the gate table says it does " ++
744 "and refuses the action when it is off.\n" ++
745 " One of them is out of date.\n",
746 .{ entry.key_ptr.*, flag },
747 );
748 return error.RefusalBlamesAFlagZigReads;
749 }
750 }
751}
752
646753test "every flag Zig can read is a flag the spec's config actually has" {
647754 // A misspelled key is not a one-flag bug. A key that is not in the file
648755 // reads as missing, and one missing key makes the whole decode throw, so