Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,85 @@ - (NSString *)accessibilityValue {
return [NSString stringWithFormat:@"%lu of 6 digits", (unsigned long)self.text.length];
}
@end

// A field that reformats its own text on every edit into `## ### ## ##` digit groups, the way a
// digit-grouping formatter in a Flutter or React Native input does. The accessibility value is
// what the user sees — the formatted string — so it never equals the digits a `fill` sent (#2634).
// The field owns the delegate it installs, keeping the formatter out of the view controller's
// own delegate callbacks.
@interface AgentDeviceDigitGroupingTextField : UITextField <UITextFieldDelegate>
@end

static NSString *AgentDeviceFormatGroupedDigits(NSString *raw) {
NSMutableString *digits = [NSMutableString string];
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
for (NSString *chunk in [raw componentsSeparatedByCharactersInSet:nonDigits]) {
[digits appendString:chunk];
}
NSMutableArray<NSNumber *> *groupWidths = [NSMutableArray arrayWithArray:@[@2, @3, @2, @2]];
NSMutableString *formatted = [NSMutableString string];
NSUInteger cursor = 0;
for (NSNumber *width in groupWidths) {
if (cursor >= digits.length) {
break;
}
if (formatted.length > 0) {
[formatted appendString:@" "];
}
NSUInteger end = MIN(cursor + width.unsignedIntegerValue, digits.length);
[formatted appendString:[digits substringWithRange:NSMakeRange(cursor, end - cursor)]];
cursor = end;
}
// Digits past the last group keep grouping by threes rather than vanishing from the field.
while (cursor < digits.length) {
NSUInteger end = MIN(cursor + 3, digits.length);
[formatted appendString:[NSString stringWithFormat:@" %@", [digits substringWithRange:NSMakeRange(cursor, end - cursor)]]];
cursor = end;
}
return formatted;
}

static NSUInteger AgentDeviceDigitCount(NSString *text, NSUInteger limit) {
NSUInteger count = 0;
for (NSUInteger index = 0; index < limit && index < text.length; index++) {
if ([NSCharacterSet.decimalDigitCharacterSet characterIsMember:[text characterAtIndex:index]]) {
count += 1;
}
}
return count;
}

@implementation AgentDeviceDigitGroupingTextField
- (BOOL)textField:(UITextField *)textField
shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString *)string {
NSString *combined = [textField.text stringByReplacingCharactersInRange:range withString:string];
NSString *formatted = AgentDeviceFormatGroupedDigits(combined);
textField.text = formatted;
// Assigning `text` drops the caret at the end, which a real grouping formatter does not do:
// a mid-string edit must keep editing where the user put the caret. The digits before the
// caret survive the reformat unchanged, so the caret belongs before the digit that follows
// them — past the separators the grouping inserted behind them — and at the end when it
// followed all of them. Typed digits count as before the caret; a delete does not move it.
NSUInteger digitsBeforeCaret =
AgentDeviceDigitCount(textField.text, range.location) + AgentDeviceDigitCount(string, string.length);
NSUInteger caretOffset = formatted.length;
NSUInteger seen = 0;
for (NSUInteger index = 0; index < formatted.length; index++) {
if (![NSCharacterSet.decimalDigitCharacterSet characterIsMember:[formatted characterAtIndex:index]]) {
continue;
}
seen += 1;
if (seen == digitsBeforeCaret + 1) {
caretOffset = index;
break;
}
}
textField.selectedTextRange = [textField textRangeFromPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]
toPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]];
return NO;
}
@end
#endif

@implementation AgentDeviceRunnerViewController
Expand Down Expand Up @@ -408,8 +487,20 @@ - (void)viewDidLoad {
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
BOOL digitCountValue =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-count-value"];
UITextField *textField =
digitCountValue ? [[AgentDeviceDigitCountTextField alloc] init] : [[UITextField alloc] init];
BOOL digitGroupingValue =
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-grouping-value"];
UITextField *textField;
if (digitCountValue) {
textField = [[AgentDeviceDigitCountTextField alloc] init];
} else if (digitGroupingValue) {
textField = [[AgentDeviceDigitGroupingTextField alloc] init];
} else {
textField = [[UITextField alloc] init];
}
if (digitGroupingValue) {
// The formatter field is its own delegate: every edit is reformatted before it lands.
textField.delegate = (id<UITextFieldDelegate>)textField;
}
if (![NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,69 @@ extension RunnerTests {
/// the baseline did not already (residual text), or it contains baseline + request in order. A
/// value related to the entry in none of these ways, such as an OTP field announcing
/// "6 of 6 digits", is the app's own representation, so retyping cannot make it match.
///
/// The two containment clauses keep one exception (#2634): a value the request plus inserted
/// formatting alone EXPLAINS — see `textValueCompletesRequest`, which owns the whole test
/// including the baseline — is not a degraded copy of anything, and the field that normalizes
/// its content shows exactly that after receiving the request. Completion is not correctness
/// (a cents-shifting mask passes it); it only stops the value from being refused the
/// unconfirmed outcome, and nothing but exact equality verifies.
static func textEntryValueEchoes(observed: String, expected: String, baseline: String) -> Bool {
let request = textEntryRequestWithoutSubmitKeys(expected)
let residualAndRequest = baseline + request
return isOrderedSubsequence(observed, of: residualAndRequest)
|| (isOrderedSubsequence(request, of: observed) && !isOrderedSubsequence(request, of: baseline))
if isOrderedSubsequence(observed, of: residualAndRequest) {
return true
}
if textValueCompletesRequest(observed: observed, request: request, baseline: baseline) {
return false
}
return (isOrderedSubsequence(request, of: observed) && !isOrderedSubsequence(request, of: baseline))
|| isOrderedSubsequence(residualAndRequest, of: observed)
}

/// Whether the request plus inserted formatting explains the WHOLE of `observed`: every request
/// character in order under the leftmost embedding, every unconsumed character explainable as
/// neither a character of the request (an ambiguous embedding may be a dropped-and-shifted
/// copy) nor of the post-clear `baseline` (any baseline character in the value is failed-clear
/// residual, wherever a mask relocated it), and at least one insertion strictly BETWEEN the
/// request's first and last characters — entry cannot insert between two characters the same
/// burst typed, so end-only insertions (`"old123456"` for `"123456"`) stay echoes. A doubled
/// entry (`"66"` for `"6"`) leaves its surplus at the ends; a mask inserting a request
/// character (`.` for a decimal value) falls back to the echo reading; a one-character request
/// has no between. Known limit: a mask that keeps its template through the clear shares
/// characters with the value it formats (`"$0.00"` for `"$10.00"`), so its fill keeps the echo
/// reading — text cannot tell surviving template from failed-clear residual.
static func textValueCompletesRequest(observed: String, request: String, baseline: String) -> Bool {
guard !request.isEmpty, request != observed, request.count > 1 else {
return false
}
// The leftmost embedding of the request into the observed value.
var consumedOffsets = IndexSet()
var cursor = observed.startIndex
for character in request {
guard let match = observed[cursor...].firstIndex(of: character) else {
return false
}
consumedOffsets.insert(observed.distance(from: observed.startIndex, to: match))
cursor = observed.index(after: match)
}
let first = consumedOffsets.first!
let last = consumedOffsets.last!
var sawInteriorInsertion = false
for (offset, character) in observed.enumerated() where !consumedOffsets.contains(offset) {
// A request character makes the embedding ambiguous; a baseline character may be residual
// a mask relocated into the span. Either way the request plus formatting does not explain
// the value.
if request.contains(character) || baseline.contains(character) {
return false
}
if offset > first && offset < last {
sawInteriorInsertion = true
}
}
return sawInteriorInsertion
}

/// Classifies a replacement whose read-back never matched. The entry is unconfirmed, not failed,
/// only when the same element's value moved off its pre-entry baseline to one that does not echo
/// the request; every other mismatch stays a failure.
Expand Down
Loading
Loading