Skip to content

Commit 728a423

Browse files
committed
test(ios): parse nested accessibility metadata
1 parent 4629b3a commit 728a423

1 file changed

Lines changed: 159 additions & 30 deletions

File tree

apps/ios/Tests/OpenClawTypographyTests.swift

Lines changed: 159 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ struct OpenClawTypographyTests {
361361
"Image(systemName: \"circle\").accessibilityLabel(Text(title))",
362362
".accessibilityLabel(Text(title)).accessibilityIdentifier(\"status\")",
363363
"Image(systemName: \"circle\").accessibilityLabel(Text(title)).accessibilityHint(Text(hint))",
364+
".accessibilityLabel(Text(self.statusLabel ?? LocalizedStringResource(\"Allowed\")))",
364365
".accessibilityLabel(Text(title))\n)",
365366
".accessibilityLabel(\n Text(title)\n)",
366367
"Image(systemName: \"circle\")\n .accessibilityLabel(\n Text(title)\n )",
@@ -492,6 +493,7 @@ struct OpenClawTypographyTests {
492493
return try self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
493494
let source = try String(contentsOf: url, encoding: .utf8)
494495
let lines = source.components(separatedBy: .newlines)
496+
let accessibilityMetadataTextLines = self.accessibilityMetadataTextLines(in: lines)
495497
return lines.indices.compactMap { idx -> String? in
496498
let rawLine = lines[idx]
497499
let line = rawLine.trimmingCharacters(in: .whitespaces)
@@ -503,7 +505,7 @@ struct OpenClawTypographyTests {
503505
|| self.hasAllowedBrandedFontParameter(window, line: rawLine, in: url)
504506

505507
if self.isTextOrLabelCall(rawLine),
506-
!self.isAccessibilityMetadataTextCall(at: idx, in: lines),
508+
!accessibilityMetadataTextLines.contains(idx),
507509
!hasLocalFont
508510
{
509511
return "\(self.relativePath(url)):\(idx + 1): \(line)"
@@ -523,40 +525,167 @@ struct OpenClawTypographyTests {
523525
}
524526

525527
private static func isAccessibilityMetadataTextCall(at idx: Int, in lines: [String]) -> Bool {
526-
let rawLine = lines[idx]
527-
let textArgument = #"(?:[^()"\\]|\\.|"(?:\\.|[^"\\])*")*"#
528-
let inlineAccessibilityText =
529-
#"\.accessibility(?:Label|Value|Hint)\s*\(\s*Text\s*\(\#(textArgument)\)\s*\)"#
530-
531-
var inlineRemainder = rawLine
532-
var removedInlineMetadata = false
533-
while let range = inlineRemainder.range(of: inlineAccessibilityText, options: .regularExpression) {
534-
inlineRemainder.removeSubrange(range)
535-
removedInlineMetadata = true
528+
self.accessibilityMetadataTextLines(in: lines).contains(idx)
529+
}
530+
531+
private static func accessibilityMetadataTextLines(in lines: [String]) -> Set<Int> {
532+
let code = self.maskedSwiftCode(lines.joined(separator: "\n"))
533+
let textToken = Array("Text".utf8)
534+
let labelToken = Array("Label".utf8)
535+
let modifiers = [
536+
Array(".accessibilityLabel".utf8),
537+
Array(".accessibilityValue".utf8),
538+
Array(".accessibilityHint".utf8),
539+
]
540+
var callCounts: [Int: Int] = [:]
541+
var metadataCallCounts: [Int: Int] = [:]
542+
var line = 0
543+
544+
for offset in code.indices {
545+
if code[offset] == 10 {
546+
line += 1
547+
continue
548+
}
549+
if self.callOpeningParenthesis(after: textToken, at: offset, in: code) != nil ||
550+
self.callOpeningParenthesis(after: labelToken, at: offset, in: code) != nil
551+
{
552+
callCounts[line, default: 0] += 1
553+
}
554+
555+
for modifier in modifiers {
556+
guard let modifierOpen = self.callOpeningParenthesis(after: modifier, at: offset, in: code)
557+
else { continue }
558+
let textOffset = self.skippingWhitespace(in: code, after: modifierOpen)
559+
guard let textOpen = self.callOpeningParenthesis(after: textToken, at: textOffset, in: code),
560+
let textClose = self.matchingParenthesis(in: code, openingAt: textOpen)
561+
else { continue }
562+
let metadataClose = self.skippingWhitespace(in: code, after: textClose)
563+
guard metadataClose < code.count, code[metadataClose] == 41 else { continue }
564+
565+
// Exempt only the direct Text argument; visible calls sharing its source line stay audited.
566+
let textLine = line + code[offset..<textOffset].count(where: { $0 == 10 })
567+
metadataCallCounts[textLine, default: 0] += 1
568+
}
536569
}
537-
if removedInlineMetadata {
538-
// Do not let metadata Text hide a visual Text or Label sharing the audited line.
539-
return !self.isTextOrLabelCall(inlineRemainder)
570+
571+
return Set(metadataCallCounts.compactMap { line, count in
572+
callCounts[line] == count ? line : nil
573+
})
574+
}
575+
576+
private static func callOpeningParenthesis(
577+
after token: [UInt8],
578+
at offset: Int,
579+
in code: [UInt8]) -> Int?
580+
{
581+
guard offset + token.count <= code.count,
582+
code[offset..<(offset + token.count)].elementsEqual(token),
583+
token.first == 46 || offset == 0 || !self.isSwiftIdentifierByte(code[offset - 1])
584+
else { return nil }
585+
let afterToken = offset + token.count
586+
guard afterToken == code.count || !self.isSwiftIdentifierByte(code[afterToken]) else { return nil }
587+
let opening = self.skippingWhitespace(in: code, after: afterToken - 1)
588+
return opening < code.count && code[opening] == 40 ? opening : nil
589+
}
590+
591+
private static func skippingWhitespace(in code: [UInt8], after offset: Int) -> Int {
592+
var cursor = offset + 1
593+
while cursor < code.count, code[cursor] == 9 || code[cursor] == 10 || code[cursor] == 13 || code[cursor] == 32 {
594+
cursor += 1
540595
}
596+
return cursor
597+
}
541598

542-
let directText = #"^\s*Text\s*\(\#(textArgument)\)"#
543-
guard let range = rawLine.range(of: directText, options: .regularExpression) else { return false }
544-
let remainder = rawLine.replacingCharacters(in: range, with: "")
545-
guard !self.isTextOrLabelCall(remainder) else { return false }
546-
547-
let contextStart = max(lines.startIndex, idx - 4)
548-
// Comment text cannot open a metadata modifier for a following visual Text.
549-
let leadingContext = lines[contextStart..<idx]
550-
.map { line in
551-
guard let comment = line.range(of: "//") else { return line }
552-
return String(line[..<comment.lowerBound])
599+
private static func matchingParenthesis(in code: [UInt8], openingAt offset: Int) -> Int? {
600+
var depth = 0
601+
for cursor in offset..<code.count {
602+
if code[cursor] == 40 {
603+
depth += 1
604+
} else if code[cursor] == 41 {
605+
depth -= 1
606+
if depth == 0 { return cursor }
553607
}
554-
.joined(separator: "\n")
555-
let accessibilityOpener =
556-
#"(?s)(?:^|\n)[^\n]*\.accessibility(?:Label|Value|Hint)\s*\(\s*$"#
608+
}
609+
return nil
610+
}
611+
612+
private static func isSwiftIdentifierByte(_ byte: UInt8) -> Bool {
613+
byte == 95 || (48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) || byte >= 128
614+
}
557615

558-
// Direct accessibility metadata is announced, not rendered. Complex expressions fail closed.
559-
return leadingContext.range(of: accessibilityOpener, options: .regularExpression) != nil
616+
private static func maskedSwiftCode(_ source: String) -> [UInt8] {
617+
let sourceBytes = Array(source.utf8)
618+
var code = sourceBytes
619+
var cursor = 0
620+
621+
func mask(_ range: Range<Int>) {
622+
for offset in range where code[offset] != 10 && code[offset] != 13 {
623+
code[offset] = 32
624+
}
625+
}
626+
627+
while cursor < sourceBytes.count {
628+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 47 {
629+
let start = cursor
630+
while cursor < sourceBytes.count, sourceBytes[cursor] != 10 {
631+
cursor += 1
632+
}
633+
mask(start..<cursor)
634+
continue
635+
}
636+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 42 {
637+
let start = cursor
638+
var depth = 1
639+
cursor += 2
640+
while cursor < sourceBytes.count, depth > 0 {
641+
if cursor + 1 < sourceBytes.count, sourceBytes[cursor] == 47, sourceBytes[cursor + 1] == 42 {
642+
depth += 1
643+
cursor += 2
644+
} else if cursor + 1 < sourceBytes.count,
645+
sourceBytes[cursor] == 42,
646+
sourceBytes[cursor + 1] == 47
647+
{
648+
depth -= 1
649+
cursor += 2
650+
} else {
651+
cursor += 1
652+
}
653+
}
654+
mask(start..<cursor)
655+
continue
656+
}
657+
658+
let start = cursor
659+
var hashCount = 0
660+
while cursor < sourceBytes.count, sourceBytes[cursor] == 35 {
661+
hashCount += 1
662+
cursor += 1
663+
}
664+
guard cursor < sourceBytes.count, sourceBytes[cursor] == 34 else {
665+
cursor = start + 1
666+
continue
667+
}
668+
let quoteCount = cursor + 2 < sourceBytes.count &&
669+
sourceBytes[cursor + 1] == 34 && sourceBytes[cursor + 2] == 34 ? 3 : 1
670+
cursor += quoteCount
671+
while cursor < sourceBytes.count {
672+
if hashCount == 0, quoteCount == 1, sourceBytes[cursor] == 92 {
673+
cursor = min(sourceBytes.count, cursor + 2)
674+
continue
675+
}
676+
let closingEnd = cursor + quoteCount + hashCount
677+
if closingEnd <= sourceBytes.count,
678+
sourceBytes[cursor..<(cursor + quoteCount)].allSatisfy({ $0 == 34 }),
679+
sourceBytes[(cursor + quoteCount)..<closingEnd].allSatisfy({ $0 == 35 })
680+
{
681+
cursor = closingEnd
682+
break
683+
}
684+
cursor += 1
685+
}
686+
mask(start..<cursor)
687+
}
688+
return code
560689
}
561690

562691
private static func isShorthandControlCall(_ line: String) -> Bool {

0 commit comments

Comments
 (0)