diff --git a/native/cu-helper/Sources/cu-helper/AXTree.swift b/native/cu-helper/Sources/cu-helper/AXTree.swift index 2c83d885..7381683a 100644 --- a/native/cu-helper/Sources/cu-helper/AXTree.swift +++ b/native/cu-helper/Sources/cu-helper/AXTree.swift @@ -600,13 +600,20 @@ public enum AXTree { /// Re-walk a freshly resolved current root through immutable topology /// evidence. Every hop compares the complete ordered sibling fingerprint - /// list before using its selected ordinal. + /// list; duplicate wrappers additionally require unique direct-child + /// topology before one candidate can be selected. private static func walk(root: AXUIElement, path: [SnapshotPathStep]) -> AXUIElement? { var current = root for step in path { let kids = walkChildren(of: current) let currentFingerprints = kids.map(fingerprint(of:)) - guard let childIndex = step.selectedIndex(in: currentFingerprints) else { + guard let childIndex = step.selectedIndex( + in: currentFingerprints, + childTopologyAt: { index in + guard kids.indices.contains(index) else { return nil } + return walkChildren(of: kids[index]).map(fingerprint(of:)) + } + ) else { return nil } current = kids[childIndex] @@ -1035,6 +1042,7 @@ public enum AXTree { snapshotPath: appendingPathStep( to: snapshotPath, selectedIndex: i, + childElements: childElements, childFingerprints: childFingerprints ), depth: depth, @@ -1172,6 +1180,7 @@ public enum AXTree { snapshotPath: appendingPathStep( to: snapshotPath, selectedIndex: i, + childElements: childElements, childFingerprints: childFingerprints ), depth: depth + 1, @@ -1267,12 +1276,18 @@ public enum AXTree { private func appendingPathStep( to path: [SnapshotPathStep]?, selectedIndex: Int, + childElements: [AXUIElement], childFingerprints: [ElementFingerprint] ) -> [SnapshotPathStep]? { guard let path, let step = SnapshotPathStep( selectedIndex: selectedIndex, - childFingerprints: childFingerprints + childFingerprints: childFingerprints, + childTopologyAt: { index in + guard childElements.indices.contains(index) else { return nil } + return AXTree.walkChildren(of: childElements[index]) + .map(AXTree.fingerprint(of:)) + } ) else { return nil } return path + [step] diff --git a/native/cu-helper/Sources/cu-helper/ElementFingerprint.swift b/native/cu-helper/Sources/cu-helper/ElementFingerprint.swift index 1334a82c..2096b28a 100644 --- a/native/cu-helper/Sources/cu-helper/ElementFingerprint.swift +++ b/native/cu-helper/Sources/cu-helper/ElementFingerprint.swift @@ -72,31 +72,69 @@ struct SnapshotElementHandle: Sendable, Equatable { } /// Immutable topology evidence for one parent→child hop. An ordinal alone is -/// never enough: every sibling fingerprint and their order must still match, -/// and the selected fingerprint must be unique among those siblings. +/// never enough: every sibling fingerprint and their order must still match. +/// A duplicate featureless wrapper may be selected only when its ordered direct +/// child topology is unique among the otherwise-identical siblings. struct SnapshotPathStep: Sendable, Hashable { let selectedIndex: Int let childFingerprints: [ElementFingerprint] + private let selectedChildTopology: [ElementFingerprint]? - init?(selectedIndex: Int, childFingerprints: [ElementFingerprint]) { + init?( + selectedIndex: Int, + childFingerprints: [ElementFingerprint], + childTopologyAt: ((Int) -> [ElementFingerprint]?)? = nil + ) { guard childFingerprints.indices.contains(selectedIndex) else { return nil } let selected = childFingerprints[selectedIndex] - guard childFingerprints.filter({ selected.matches($0) }).count == 1 else { - return nil + let matchingIndices = childFingerprints.indices.filter { + selected.matches(childFingerprints[$0]) + } + if matchingIndices.count == 1 { + selectedChildTopology = nil + } else { + guard let childTopologyAt, + let selectedTopology = childTopologyAt(selectedIndex) + else { return nil } + var topologyMatches = 0 + for index in matchingIndices { + let topology: [ElementFingerprint]? + if index == selectedIndex { + topology = selectedTopology + } else { + topology = childTopologyAt(index) + } + guard let topology else { return nil } + if topology == selectedTopology { topologyMatches += 1 } + } + guard topologyMatches == 1 else { return nil } + selectedChildTopology = selectedTopology } self.selectedIndex = selectedIndex self.childFingerprints = childFingerprints } - func selectedIndex(in currentFingerprints: [ElementFingerprint]) -> Int? { - guard childFingerprints == currentFingerprints, - currentFingerprints.indices.contains(selectedIndex) - else { return nil } - let selected = currentFingerprints[selectedIndex] - guard currentFingerprints.filter({ selected.matches($0) }).count == 1 else { - return nil + func selectedIndex( + in currentFingerprints: [ElementFingerprint], + childTopologyAt: ((Int) -> [ElementFingerprint]?)? = nil + ) -> Int? { + guard childFingerprints == currentFingerprints else { return nil } + let selected = childFingerprints[selectedIndex] + let matchingIndices = currentFingerprints.indices.filter { + selected.matches(currentFingerprints[$0]) } - return selectedIndex + guard let selectedChildTopology else { + return matchingIndices.count == 1 ? matchingIndices[0] : nil + } + guard let childTopologyAt else { return nil } + var topologyMatches: [Int] = [] + for index in matchingIndices { + guard let topology = childTopologyAt(index) else { return nil } + if topology == selectedChildTopology { + topologyMatches.append(index) + } + } + return topologyMatches.count == 1 ? topologyMatches[0] : nil } } diff --git a/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift b/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift index de1fe5cf..a6d2bf06 100644 --- a/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/AXTreePublicationIntegrationTests.swift @@ -13,7 +13,7 @@ final class AXTreePublicationIntegrationTests: XCTestCase { private static let fixtureStopPath = "CC_HAHA_AX_PUBLICATION_STOP" private static let fixtureTitle = "CC_HAHA_AX_PUBLICATION_TITLE" - func testPublishedDescriptionOnlyControlClicksImmediatelyAndRejectsOldGeneration() async throws { + func testPublishedControlBelowDuplicateAncestorClicksImmediatelyAndRejectsOldGeneration() async throws { if ProcessInfo.processInfo.environment[Self.fixtureFlag] == "1" { try await runFixtureProcess() return @@ -129,19 +129,35 @@ final class AXTreePublicationIntegrationTests: XCTestCase { defer: false ) window.title = title - // TextEdit exposes these toolbar checkboxes with nil title/identifier; - // their AX descriptions are the only immutable semantic distinction. - let controls = ["Bold", "Italic", "Underline"].map { label -> NSButton in + // TextEdit exposes formatting and alignment segments as two sibling + // AXGroups whose own fingerprints are identical. Their description-only + // children are the first semantic evidence that distinguishes the paths. + let formattingControls = ["Bold", "Italic", "Underline"].map { label -> NSButton in let button = NSButton(checkboxWithTitle: "", target: nil, action: nil) button.setAccessibilityLabel(label) return button } - let stack = NSStackView(views: controls) - stack.frame = NSRect(x: 20, y: 60, width: 380, height: 60) - stack.orientation = .horizontal - stack.spacing = 12 + let alignmentControls = ["Align Left", "Align Center", "Align Right"].map { + label -> NSButton in + let button = NSButton(checkboxWithTitle: "", target: nil, action: nil) + button.setAccessibilityLabel(label) + return button + } + let formattingGroup = NSStackView(views: formattingControls) + formattingGroup.frame = NSRect(x: 20, y: 60, width: 180, height: 60) + formattingGroup.orientation = .horizontal + formattingGroup.spacing = 12 + formattingGroup.setAccessibilityElement(true) + formattingGroup.setAccessibilityRole(.group) + let alignmentGroup = NSStackView(views: alignmentControls) + alignmentGroup.frame = NSRect(x: 220, y: 60, width: 180, height: 60) + alignmentGroup.orientation = .horizontal + alignmentGroup.spacing = 12 + alignmentGroup.setAccessibilityElement(true) + alignmentGroup.setAccessibilityRole(.group) let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 180)) - content.addSubview(stack) + content.addSubview(formattingGroup) + content.addSubview(alignmentGroup) window.contentView = content window.makeKeyAndOrderFront(nil) app.activate() diff --git a/native/cu-helper/Tests/CuHelperTests/ElementFingerprintTests.swift b/native/cu-helper/Tests/CuHelperTests/ElementFingerprintTests.swift index 178e87d0..93946064 100644 --- a/native/cu-helper/Tests/CuHelperTests/ElementFingerprintTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/ElementFingerprintTests.swift @@ -108,6 +108,77 @@ final class ElementFingerprintTests: XCTestCase { ) } + func testPathStepUsesUniqueChildTopologyForDuplicateAncestor() throws { + let group = ElementFingerprint( + role: "AXGroup", + subrole: nil, + identifier: nil, + title: nil, + valueKind: nil + ) + let formatting = ["Bold", "Italic", "Underline"].map { label in + ElementFingerprint( + role: "AXCheckBox", + subrole: "AXSegment", + identifier: nil, + title: nil, + label: label, + valueKind: "boolean" + ) + } + let alignment = ["Align Left", "Align Center", "Align Right"].map { label in + ElementFingerprint( + role: "AXCheckBox", + subrole: "AXSegment", + identifier: nil, + title: nil, + label: label, + valueKind: "boolean" + ) + } + let publishedTopologies = [formatting, alignment] + let step = try XCTUnwrap( + SnapshotPathStep( + selectedIndex: 0, + childFingerprints: [group, group], + childTopologyAt: { publishedTopologies[$0] } + ) + ) + + XCTAssertEqual( + step.selectedIndex( + in: [group, group], + childTopologyAt: { [alignment, formatting][$0] } + ), + 1 + ) + XCTAssertNil( + step.selectedIndex( + in: [group, group], + childTopologyAt: { _ in formatting } + ) + ) + } + + func testPathStepRejectsDuplicateAncestorWithIdenticalChildTopology() { + let group = ElementFingerprint( + role: "AXGroup", + subrole: nil, + identifier: nil, + title: nil, + valueKind: nil + ) + let formatting = [button("bold"), button("italic")] + + XCTAssertNil( + SnapshotPathStep( + selectedIndex: 0, + childFingerprints: [group, group], + childTopologyAt: { _ in formatting } + ) + ) + } + func testDescriptionOnlySiblingControlsRemainUniquelyAddressable() throws { let controls = ["Bold", "Italic", "Underline"].map { label in ElementFingerprint(