diff --git a/.agents/skills/openswiftui-test-authoring/SKILL.md b/.agents/skills/openswiftui-test-authoring/SKILL.md index a155ba542..127a1f0bb 100644 --- a/.agents/skills/openswiftui-test-authoring/SKILL.md +++ b/.agents/skills/openswiftui-test-authoring/SKILL.md @@ -20,6 +20,8 @@ description: Route OpenSwiftUI test authoring and review work to the appropriate - Follow the organization and naming of the nearest tests in the same target. - Sort import declarations alphabetically by imported module name, preserving any attributes attached to each declaration. +- Mark suites/tests that contain AI-generated Swift Testing tests with `.tags(.aigc)` + on `@Suite` or `@Test`. Also add `import OpenSwiftUITestsSupport` if not imported yet. - In Swift Testing, prefer `@Test(arguments:)` when cases share the same test body. Keep different behaviors in separate tests. - Give argument collections explicit types when inference is ambiguous, diff --git a/Sources/OpenSwiftUICore/Event/Gesture/GestureInputs.swift b/Sources/OpenSwiftUICore/Event/Gesture/GestureInputs.swift index b79929da7..3c0635eb2 100644 --- a/Sources/OpenSwiftUICore/Event/Gesture/GestureInputs.swift +++ b/Sources/OpenSwiftUICore/Event/Gesture/GestureInputs.swift @@ -83,6 +83,15 @@ public struct _GestureInputs { } } + package var position: Attribute { + let defaultPosition = intern(ViewOrigin.zero, id: .defaultValue) + return viewSubgraph.apply { + let position = IndirectAttribute(source: defaultPosition) + position.source = viewInputs.position + return position.projectedValue + } + } + package var size: Attribute { let defaultSize = intern(ViewSize.zero, id: .defaultValue) return viewSubgraph.apply { diff --git a/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift b/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift index 8159ae504..a18e6e4d4 100644 --- a/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift +++ b/Sources/OpenSwiftUICore/Event/Gesture/LayoutGesture.swift @@ -3,15 +3,19 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete +// ID: 05F3243F43C616B77CCF383885E80E96 (SwiftUICore) -// MARK: - LayoutGesture [WIP] +import Foundation +import OpenAttributeGraphShims + +// MARK: - LayoutGesture package protocol LayoutGesture: PrimitiveDebuggableGesture, PrimitiveGesture where Value == () { var responder: MultiViewResponder { get } func updateEventBindings( - _ events: inout [EventID : any EventType], + _ events: inout [EventID: any EventType], proxy: LayoutGestureChildProxy ) } @@ -21,18 +25,99 @@ extension LayoutGesture { gesture: _GraphValue, inputs: _GestureInputs ) -> _GestureOutputs { - _openSwiftUIUnimplementedFailure() + let box = LayoutGestureBox(inputs: inputs) + let boxValue = Attribute(UpdateLayoutGestureBox( + gesture: gesture.value, + events: inputs.events, + resetSeed: inputs.resetSeed, + box: box + )) + let phase = Attribute(LayoutPhase(gesture: gesture.value, boxValue: boxValue)) + var outputs = _GestureOutputs(phase: phase) + if inputs.options.contains(.includeDebugOutput) { + outputs.debugData = Attribute(LayoutDebug( + gestureType: Self.self, + phase: phase, + boxValue: boxValue, + resetSeed: inputs.resetSeed, + position: inputs.position, + size: inputs.size, + transform: inputs.transform + )) + } + for key in inputs.preferences.keys { + func project(_ key: K.Type) { + outputs[key] = Attribute(LayoutGesturePreferenceCombiner( + gesture: gesture.value, + boxValue: boxValue + )) + } + project(key) + } + return outputs } package func updateEventBindings( - _ events: inout [EventID : any EventType], + _ events: inout [EventID: any EventType], proxy: LayoutGestureChildProxy ) { _openSwiftUIEmptyStub() } + + fileprivate func childEvents( + events: [EventID: any EventType], + index: Int, + box: LayoutGestureBox + ) -> [EventID: any EventType] { + let child = box.children[index] + if child.seenEventIDs.isEmpty { + return events.optimisticFilter { _, event in + event.binding.map(child.binds) ?? false + } + } + var result: [EventID: any EventType] = [:] + for (id, event) in events { + if let binding = event.binding, child.binds(binding) { + result[id] = event + } else if child.seenEventIDs.contains(id) { + var event = event + event.binding = nil + result[id] = event + } + } + return result + } + + fileprivate func phase(box: LayoutGestureBox) -> GesturePhase { + box.children.filter { !$0.seenEventIDs.isEmpty } + .map { $0.phase!.value.withValue(()) } + .merged() + } + + fileprivate func preferenceValue( + key: K.Type, + box: LayoutGestureBox + ) -> K.Value { + var value = K.defaultValue + var isFirst = true + for child in box.children { + guard !child.seenEventIDs.isEmpty, + let preferences = child.preferences, + let attribute = preferences[key] else { + continue + } + if isFirst { + value = attribute.value + } else { + K.reduce(value: &value) { attribute.value } + } + isFirst = false + } + return value + } } -// MARK: - DefaultLayoutGesture [WIP] +// MARK: - DefaultLayoutGesture package struct DefaultLayoutGesture: LayoutGesture { package var responder: MultiViewResponder @@ -41,29 +126,201 @@ package struct DefaultLayoutGesture: LayoutGesture { package typealias Value = () } -// MARK: - LayoutGestureChildProxy [WIP] +// MARK: - LayoutGestureBox + +private final class LayoutGestureBox { + let inputs: _GestureInputs + weak var bindingManager: EventBindingManager? + let parentSubgraph: Subgraph + var children: [Child] = [] + var nextUniqueId: UInt32 = 0 + var seed: UInt32 = 0 + var resetSeed: UInt32 = 0 + + struct Value { + let box: LayoutGestureBox + let seed: UInt32 + } + + struct Child { + let responder: ViewResponder + let uniqueId: UInt32 + var resetDelta: UInt32 = 0 + var subgraph: Subgraph? + var phase: Attribute>? + var events: [EventID: any EventType] = [:] + var seenEventIDs: Set = [] + var debugData: DebugData? + var preferences: PreferencesOutputs? + + enum DebugData { + case reset(GestureDebug.Data) + case attribute(Attribute) + } + + func binds(_ binding: EventBinding) -> Bool { + binding.responder.isDescendant(of: responder) + } + + mutating func reset() { + guard !seenEventIDs.isEmpty else { + return + } + if phase != nil { + if case let .attribute(attribute)? = debugData { + debugData = .reset(attribute.value) + } + phase = nil + subgraph?.willInvalidate(isInserted: true) + subgraph?.invalidate() + subgraph = nil + responder.resetGesture() + } + events = [:] + seenEventIDs = [] + resetDelta &+= 1 + } + } + + init(inputs: _GestureInputs) { + self.inputs = inputs + bindingManager = EventBindingManager.current + parentSubgraph = Subgraph.current! + } + + func updateResetSeed(_ resetSeed: UInt32) { + guard self.resetSeed != resetSeed else { + return + } + self.resetSeed = resetSeed + for index in children.indices { + children[index].reset() + seed &+= 1 + } + seed &+= 1 + } + + func updateResponder(_ responder: MultiViewResponder) { + var count = children.count + var index = 0 + var changed = false + for responder in responder.children { + if let match = (index..( + _ events: [EventID: any EventType], + gesture: G, + boxValueAttribute: Attribute + ) { + for index in children.indices where !children[index].events.isEmpty { + children[index].events = [:] + seed &+= 1 + } + guard !events.isEmpty else { + return + } + var events = events + gesture.updateEventBindings(&events, proxy: LayoutGestureChildProxy(box: self)) + for index in children.indices { + let childEvents = gesture.childEvents(events: events, index: index, box: self) + guard !childEvents.isEmpty else { + continue + } + children[index].seenEventIDs.formUnion(childEvents.keys) + children[index].events = childEvents + seed &+= 1 + guard children[index].phase == nil else { + continue + } + let outputs: _GestureOutputs + if parentSubgraph.isValid { + let uniqueId = children[index].uniqueId + let subgraph = Subgraph(graph: parentSubgraph.graph) + parentSubgraph.addChild(subgraph) + outputs = subgraph.apply { + var inputs = self.inputs + inputs.copyCaches() + inputs.events = Attribute(LayoutChildEvents( + boxValue: boxValueAttribute, + uniqueId: uniqueId + )) + inputs.resetSeed = Attribute(LayoutChildSeed( + boxValue: boxValueAttribute, + uniqueId: uniqueId + )) + return children[index].responder.makeGesture(inputs: inputs) + } + children[index].subgraph = subgraph + } else { + outputs = _GestureOutputs(phase: inputs.failedPhase) + } + children[index].phase = outputs.phase + children[index].debugData = outputs.debugData.map(Child.DebugData.attribute) + children[index].preferences = outputs.preferences + } + } + + func resetTerminalChildren(gesture: G) { + for index in children.indices { + guard !children[index].seenEventIDs.isEmpty, + children[index].phase!.value.isTerminal else { + continue + } + children[index].reset() + seed &+= 1 + } + } +} + +// MARK: - LayoutGestureChildProxy package struct LayoutGestureChildProxy: RandomAccessCollection { + fileprivate let box: LayoutGestureBox + package struct Child { + fileprivate let base: LayoutGestureBox.Child + package func binds(_ binding: EventBinding) -> Bool { - _openSwiftUIUnimplementedFailure() + base.binds(binding) } package func containsGlobalLocation(_ p: PlatformPoint) -> Bool { - _openSwiftUIUnimplementedFailure() + base.responder.containsGlobalPoints([p], cacheKey: nil, options: []).mask[0] } } - package var startIndex: Int { - get { _openSwiftUIUnimplementedFailure() } - } + package var startIndex: Int { 0 } - package var endIndex: Int { - get { _openSwiftUIUnimplementedFailure() } - } + package var endIndex: Int { box.children.count } package subscript(index: Int) -> LayoutGestureChildProxy.Child { - get { _openSwiftUIUnimplementedFailure() } + Child(base: box.children[index]) } package func bindChild( @@ -71,6 +328,165 @@ package struct LayoutGestureChildProxy: RandomAccessCollection { event: any EventType, id: EventID ) -> (from: EventBinding?, to: EventBinding?)? { - _openSwiftUIUnimplementedFailure() + var responder = box.children[index].responder + if let event = HitTestableEvent(event) { + responder = responder.hitTest( + globalPoint: event.hitTestLocation, + radius: event.hitTestRadius, + cacheKey: nil, + options: [] + ) ?? responder + } + guard let change = box.bindingManager?.rebindEvent(id, to: responder) else { + return nil + } + if let oldBinding = change.from, + let index = box.children.firstIndex(where: { $0.binds(oldBinding) }) { + box.children[index].resetDelta &+= 1 + box.seed &+= 1 + } + return change + } +} + +// MARK: - UpdateLayoutGestureBox + +private struct UpdateLayoutGestureBox: Rule { + @Attribute var gesture: G + @Attribute var events: [EventID: any EventType] + @Attribute var resetSeed: UInt32 + let box: LayoutGestureBox + + var value: LayoutGestureBox.Value { + box.updateResetSeed(resetSeed) + let (gesture, changed) = $gesture.changedValue() + if changed { + box.updateResponder(gesture.responder) + } + box.willSendEvents(events, gesture: gesture, boxValueAttribute: attribute) + return .init(box: box, seed: box.seed) + } +} + +// MARK: - LayoutChildEvents + +private struct LayoutChildEvents: Rule { + @Attribute var boxValue: LayoutGestureBox.Value + let uniqueId: UInt32 + + var value: [EventID: any EventType] { + boxValue.box.children.first { $0.uniqueId == uniqueId }?.events ?? [:] + } +} + +// MARK: - LayoutChildSeed + +private struct LayoutChildSeed: Rule { + @Attribute var boxValue: LayoutGestureBox.Value + let uniqueId: UInt32 + + var value: UInt32 { + let box = boxValue.box + let delta = box.children.first { $0.uniqueId == uniqueId }?.resetDelta ?? 0x10000 + return box.resetSeed &+ delta + } +} + +// MARK: - LayoutPhase + +private struct LayoutPhase: Rule { + @Attribute var gesture: G + @Attribute var boxValue: LayoutGestureBox.Value + + var value: GesturePhase { + let box = boxValue.box + let phase = gesture.phase(box: box) + box.resetTerminalChildren(gesture: gesture) + return phase + } +} + +// MARK: - LayoutGesturePreferenceCombiner + +private struct LayoutGesturePreferenceCombiner: Rule, AsyncAttribute { + @Attribute var gesture: G + @Attribute var boxValue: LayoutGestureBox.Value + + static var initialValue: K.Value? { K.defaultValue } + + var value: K.Value { + gesture.preferenceValue(key: K.self, box: boxValue.box) + } +} + +// MARK: - LayoutDebug + +private struct LayoutDebug: Rule { + var gestureType: G.Type + @Attribute var phase: GesturePhase + @Attribute var boxValue: LayoutGestureBox.Value + @Attribute var resetSeed: UInt32 + @Attribute var position: ViewOrigin + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + + var value: GestureDebug.Data { + let children = boxValue.box.children.compactMap { child -> GestureDebug.Data? in + switch child.debugData { + case let .reset(data)?: data + case let .attribute(attribute)?: attribute.value + case nil: nil + } + } + let origin = transform.convert(.localToSpace(.global), point: position) + return GestureDebug.Data( + kind: .combiner, + type: gestureType, + children: .init(children), + phase: phase, + attribute: $boxValue.identifier, + resetSeed: resetSeed, + frame: CGRect(origin: origin, size: size.value), + properties: .init() + ) + } +} + +// MARK: - GesturePhase Collection + merged + +extension Collection where Element == GesturePhase { + fileprivate func merged() -> GesturePhase { + var allFailed = true + var allTerminal = true + var hasActiveOrEnded = false + var allHaveValue = true + for phase in self { + switch phase { + case .failed: + break + case let .possible(value): + allFailed = false + allTerminal = false + if value == nil { + allHaveValue = false + } + case .active: + allFailed = false + allTerminal = false + hasActiveOrEnded = true + case .ended: + allFailed = false + hasActiveOrEnded = true + } + } + if allFailed { + return .failed + } else if allTerminal { + return .ended(()) + } else if hasActiveOrEnded { + return .active(()) + } else { + return .possible(allHaveValue ? () : nil) + } } } diff --git a/Sources/OpenSwiftUITestsSupport/Testing/TagAddition.swift b/Sources/OpenSwiftUITestsSupport/Testing/TagAddition.swift new file mode 100644 index 000000000..17088d307 --- /dev/null +++ b/Sources/OpenSwiftUITestsSupport/Testing/TagAddition.swift @@ -0,0 +1,9 @@ +// +// TagAddition.swift +// OpenSwiftUITestsSupport + +package import Testing + +extension Tag { + @Tag package static var aigc: Tag +} diff --git a/Tests/OpenSwiftUICoreTests/Event/Event/EventDebugTests.swift b/Tests/OpenSwiftUICoreTests/Event/Event/EventDebugTests.swift index c4aa6596a..542347dea 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Event/EventDebugTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Event/EventDebugTests.swift @@ -3,9 +3,10 @@ // OpenSwiftUICoreTests @_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing -@Suite(.serialized) +@Suite(.serialized, .tags(.aigc)) struct EventDebugTriggersTests { @Test func rawValues() { @@ -60,6 +61,7 @@ struct EventDebugTriggersTests { } } +@Suite(.tags(.aigc)) struct InheritedPhaseDescriptionTests { @Test(arguments: [ (0, "[ ]"), diff --git a/Tests/OpenSwiftUICoreTests/Event/Event/PanEventTests.swift b/Tests/OpenSwiftUICoreTests/Event/Event/PanEventTests.swift index 875bd2b9b..da7a1d17f 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Event/PanEventTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Event/PanEventTests.swift @@ -6,9 +6,10 @@ import Foundation @_spi(ForOpenSwiftUIOnly) @_spi(_) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing -@Suite +@Suite(.tags(.aigc)) struct PanEventTests { @Test func initializationFromValues() { diff --git a/Tests/OpenSwiftUICoreTests/Event/Gesture/GestureDebugTests.swift b/Tests/OpenSwiftUICoreTests/Event/Gesture/GestureDebugTests.swift index 39e879dca..5483250b4 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Gesture/GestureDebugTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Gesture/GestureDebugTests.swift @@ -11,6 +11,7 @@ import OpenAttributeGraphShims @_private(sourceFile: "GestureDebug.swift") #endif import OpenSwiftUICore +import OpenSwiftUITestsSupport #if !OPENSWIFTUI_SWIFT_LOG import OSLog #endif @@ -229,7 +230,10 @@ private func makeData( #if !OPENSWIFTUI_SWIFT_LOG @MainActor -@Suite(.disabled(if: isX86_64, "OSLogStore does not reliably return current-process log entries on x86_64 simulator.")) +@Suite( + .disabled(if: isX86_64, "OSLogStore does not reliably return current-process log entries on x86_64 simulator."), + .tags(.aigc) +) struct GestureDebugLogTests { // NOTE: entry.date has some range diff. So we can't use $0.date > date. Use count instead. @available(iOS 15, macOS 12, *) @@ -290,6 +294,7 @@ struct GestureDebugLogTests { #endif #if OPENSWIFTUI_ENABLE_PRIVATE_IMPORTS +@Suite(.tags(.aigc)) struct GestureDebugTests { @Test func frameDescriptionWithoutParent() { diff --git a/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift b/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift new file mode 100644 index 000000000..6f6d8b045 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Event/Gesture/LayoutGestureTests.swift @@ -0,0 +1,442 @@ +// +// LayoutGestureTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@MainActor +@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc)) +struct LayoutGestureTests { + @Test + func emptyLayoutFailsWithoutCreatingChildren() { + withFixture { fixture in + #expect(fixture.outputs.phase.value.isFailed) + } + } + + @Test + func rebindingDeliversAnUnboundEventToThePreviousChild() { + withFixture { fixture in + let first = TestResponder("first") + let second = TestResponder("second") + let descendant = ViewResponder() + descendant.parent = first + fixture.setChildren([first, second]) + + fixture.send(to: descendant) + #expect(fixture.outputs.phase.value.isActive) + #expect(first.events[fixture.eventID]?.binding?.responder === descendant) + #expect(second.makeCount == 0) + + fixture.send(to: second) + #expect(fixture.outputs.phase.value.isActive) + #expect(first.events.count == 1) + #expect(first.events[fixture.eventID]?.binding == nil) + #expect(second.events[fixture.eventID]?.binding?.responder === second) + #expect(first.makeCount == 1) + #expect(second.makeCount == 1) + } + } + + @Test + func reorderingPreservesChildGesturesAndPreferenceOrder() { + withFixture { fixture in + let first = TestResponder("first") + let second = TestResponder("second") + fixture.setChildren([first, second]) + fixture.send(to: [first, second]) + #expect(fixture.outputs.phase.value.isActive) + #expect(fixture.outputs[TestPreference.self]?.value == ["first", "second"]) + + fixture.setChildren([second, first]) + #expect(fixture.outputs.phase.value.isActive) + #expect(fixture.outputs[TestPreference.self]?.value == ["second", "first"]) + #expect(first.makeCount == 1) + #expect(second.makeCount == 1) + #expect(first.resetCount == 0) + #expect(second.resetCount == 0) + } + } + + @Test + func removingChildrenResetsOnlyParticipatingGestures() { + withFixture { fixture in + let first = TestResponder("first") + let unused = TestResponder("unused") + let removed = TestResponder("removed") + fixture.setChildren([first, unused, removed]) + fixture.send(to: [first, removed]) + #expect(fixture.outputs.phase.value.isActive) + + fixture.setChildren([first]) + let unusedResets = unused.resetCount + let removedResets = removed.resetCount + #expect(fixture.outputs.phase.value.isActive) + #expect(fixture.outputs[TestPreference.self]?.value == ["first"]) + #expect(first.makeCount == 1) + #expect(first.resetCount == 0) + #expect(unused.resetCount - unusedResets == 0) + #expect(removed.resetCount - removedResets == 1) + } + } + + @Test + func emptyEventsClearChildInputWithoutCallingTheBindingHook() { + withFixture { fixture in + let child = TestResponder("child") + fixture.setChildren([child]) + var calls = 0 + fixture.gesture.value.onBindings = { _, _ in calls += 1 } + fixture.send(to: child) + #expect(fixture.outputs.phase.value.isActive) + #expect(calls == 1) + + fixture.send(to: []) + #expect(fixture.outputs.phase.value.isActive) + #expect(child.events.isEmpty) + #expect(child.makeCount == 1) + #expect(calls == 1) + } + } + + @Test + func proxyRebindingAdvancesThePreviousChildSeedWithoutRecreatingIt() { + withFixture { fixture in + let first = TestResponder("first") + let second = TestResponder("second") + fixture.setChildren([first, second]) + _ = fixture.host.eventBindingManager.rebindEvent(fixture.eventID, to: first) + fixture.send(to: first) + #expect(fixture.outputs.phase.value.isActive) + + let eventID = fixture.eventID + fixture.gesture.value.onBindings = { events, proxy in + #expect(proxy.count == 2) + #expect(proxy[0].binds(EventBinding(responder: first))) + #expect(!proxy[1].binds(EventBinding(responder: first))) + #expect(proxy[1].containsGlobalLocation(CGPoint(x: 5, y: 5))) + #expect(!proxy[1].containsGlobalLocation(CGPoint(x: 20, y: 20))) + var event = events[eventID]! + let change = proxy.bindChild(index: 1, event: event, id: eventID) + #expect(change?.from?.responder === first) + #expect(change?.to?.responder === second) + #expect(proxy.bindChild(index: 1, event: event, id: eventID) == nil) + event.binding = change?.to + events[eventID] = event + } + fixture.send(to: first) + #expect(fixture.outputs.phase.value.isActive) + #expect(first.events[fixture.eventID]?.binding == nil) + #expect(first.resetSeed == 1) + #expect(first.makeCount == 1) + #expect(first.resetCount == 0) + #expect(second.events[fixture.eventID]?.binding?.responder === second) + #expect(second.resetSeed == 0) + } + } + + @Test + func terminalPhaseIsReturnedBeforeChildReset() { + withFixture { fixture in + let child = TestResponder("child") + child.phase = .ended(()) + fixture.setChildren([child]) + fixture.send(to: child) + + #expect(fixture.outputs.phase.value.isEnded) + #expect(child.resetCount == 1) + + child.phase = .active(()) + fixture.send(to: child) + #expect(fixture.outputs.phase.value.isActive) + #expect(child.makeCount == 2) + #expect(child.resetSeed == 1) + } + } + + @Test + func resetSeedWrapsAndRecreatesTheChild() { + withFixture { fixture in + let child = TestResponder("child") + fixture.resetSeed.value = .max + fixture.setChildren([child]) + fixture.send(to: child) + #expect(fixture.outputs.phase.value.isActive) + #expect(child.resetSeed == .max) + + fixture.resetSeed.value = 0 + #expect(fixture.outputs.phase.value.isActive) + #expect(child.resetSeed == 1) + #expect(child.makeCount == 2) + #expect(child.resetCount == 1) + } + } + + @Test + func preferencesUseTheFirstParticipatingValueWithoutReducingTheDefault() { + withFixture { fixture in + let first = TestResponder("first") + let second = TestResponder("second") + fixture.setChildren([first, second]) + #expect(fixture.outputs[TestPreference.self]?.value == ["default"]) + + fixture.send(to: second) + #expect(fixture.outputs[TestPreference.self]?.value == ["second"]) + #expect(first.makeCount == 0) + + fixture.resetSeed.value = 1 + fixture.events.value = [:] + #expect(fixture.outputs[TestPreference.self]?.value == ["default"]) + } + } + + @Test + func debugOutputPreservesTerminalChildDataAfterReset() { + withFixture(debugOutput: true) { fixture in + let child = TestResponder("child") + child.phase = .ended(()) + fixture.setChildren([child]) + fixture.send(to: child) + #expect(fixture.outputs.phase.value.isEnded) + #expect(child.resetCount == 1) + + let data = fixture.outputs.debugData!.value + #expect(data.kind == .combiner) + #expect(data.type == TestGesture.self) + #expect(data.phase.isEnded) + #expect(data.frame == CGRect(x: 13, y: 24, width: 30, height: 40)) + #expect(data.children.count == 1) + #expect(data.children[0].phase.isEnded) + #expect(data.children[0].properties[0].0 == "name") + #expect(data.children[0].properties[0].1 == "child") + } + } + + @Test(arguments: [ + ({ @Sendable in [] }, "failed"), + ({ @Sendable in [.failed, .failed] }, "failed"), + ({ @Sendable in [.possible(nil), .failed] }, ""), + ({ @Sendable in [.possible(()), .failed] }, "possible(some)"), + ({ @Sendable in [.possible(()), .possible(())] }, "possible(some)"), + ({ @Sendable in [.possible(()), .possible(nil)] }, ""), + ({ @Sendable in [.possible(nil), .possible(())] }, ""), + ({ @Sendable in [.active(()), .possible(nil)] }, "active"), + ({ @Sendable in [.possible(nil), .active(())] }, "active"), + ({ @Sendable in [.ended(()), .possible(nil)] }, "active"), + ({ @Sendable in [.possible(nil), .ended(())] }, "active"), + ({ @Sendable in [.ended(()), .failed] }, "ended"), + ({ @Sendable in [.failed, .ended(())] }, "ended"), + ({ @Sendable in [.ended(()), .ended(())] }, "ended"), + ({ @Sendable in [.ended(()), .active(())] }, "active"), + ] as [(@Sendable () -> [GesturePhase], String)]) + func mergesParticipatingPhases(makePhases: @Sendable () -> [GesturePhase], expected: String) { + withFixture { fixture in + let children = makePhases().enumerated().map { index, phase in + let child = TestResponder(String(index)) + child.phase = phase + return child + } + fixture.setChildren(children) + fixture.send(to: children) + #expect(fixture.outputs.phase.value.descriptionWithoutValue == expected) + } + } + + private func withFixture(debugOutput: Bool = false, _ body: (Fixture) -> Void) { + let graph = ViewGraph(rootViewType: EmptyView.self) + graph.rootSubgraph.apply { + body(Fixture(graph: graph, debugOutput: debugOutput)) + } + } +} + +private final class Fixture { + let host: TestHost + let responder = MultiViewResponder() + let events = Attribute(value: [EventID: any EventType]()) + let resetSeed = Attribute(value: UInt32.zero) + let gesture: Attribute + let outputs: _GestureOutputs + let eventID = EventID(type: TestEvent.self, serial: 0) + private var timestamp = Time.zero + + init(graph: ViewGraph, debugOutput: Bool) { + host = TestHost(viewGraph: graph) + graph.delegate = host + gesture = Attribute(value: TestGesture(responder: responder)) + var viewInputs = _ViewInputs(withoutGeometry: graph.graphInputs) + if debugOutput { + var transform = ViewTransform() + transform.appendCoordinateSpace(name: "root") + transform.appendTranslation(CGSize(width: -10, height: -20)) + viewInputs.transform = Attribute(value: transform) + viewInputs.position = Attribute(value: CGPoint(x: 3, y: 4)) + viewInputs.size = Attribute(value: ViewSize.fixed(CGSize(width: 30, height: 40))) + } + var inputs = _GestureInputs( + viewInputs, + viewSubgraph: graph.rootSubgraph, + events: events, + time: Attribute(value: Time.zero), + resetSeed: resetSeed, + inheritedPhase: Attribute(value: .failed), + gesturePreferenceKeys: Attribute(value: PreferenceKeys()) + ) + inputs.preferences.add(TestPreference.self) + inputs.options.setValue(debugOutput, for: .includeDebugOutput) + outputs = TestGesture._makeGesture(gesture: _GraphValue(gesture), inputs: inputs) + } + + func setChildren(_ children: [ViewResponder]) { + responder.children = children + gesture.value.revision += 1 + } + + func send(to responder: ViewResponder) { + send(to: [responder]) + } + + func send(to responders: [ViewResponder]) { + timestamp += 1 + events.value = Dictionary(uniqueKeysWithValues: responders.enumerated().map { index, responder in + let event: any EventType = TestEvent( + phase: .active, + timestamp: timestamp, + binding: EventBinding(responder: responder) + ) + return (EventID(type: TestEvent.self, serial: index), event) + }) + } +} + +private struct TestGesture: LayoutGesture { + var responder: MultiViewResponder + var revision = 0 + var onBindings: ((inout [EventID: any EventType], LayoutGestureChildProxy) -> Void)? + + func updateEventBindings(_ events: inout [EventID: any EventType], proxy: LayoutGestureChildProxy) { + onBindings?(&events, proxy) + } + + typealias Value = Void + typealias Body = Never +} + +private struct TestEvent: EventType { + var phase: EventPhase + var timestamp: Time + var binding: EventBinding? +} + +private enum TestPreference: PreferenceKey { + static var defaultValue: [String] { ["default"] } + + static func reduce(value: inout [String], nextValue: () -> [String]) { + value.append(contentsOf: nextValue()) + } +} + +private final class TestResponder: ViewResponder { + let name: String + var phase: GesturePhase = .active(()) + var events: [EventID: any EventType] = [:] + var resetSeed: UInt32 = 0 + var makeCount = 0 + var resetCount = 0 + + init(_ name: String) { + self.name = name + super.init() + } + + override func makeGesture(inputs: _GestureInputs) -> _GestureOutputs { + makeCount += 1 + var outputs = _GestureOutputs(phase: Attribute(Phase( + responder: self, + events: inputs.events, + resetSeed: inputs.resetSeed + ))) + outputs[TestPreference.self] = Attribute(value: [name]) + if inputs.options.contains(.includeDebugOutput) { + outputs.debugData = Attribute(value: GestureDebug.Data( + kind: .primitive, + type: TestGesture.self, + children: .init(), + phase: phase, + attribute: outputs.phase.identifier, + resetSeed: 0, + frame: .zero, + properties: .init(("name", name)) + )) + } + return outputs + } + + override func containsGlobalPoints( + _ points: [PlatformPoint], + cacheKey: UInt32?, + options: ContainsPointsOptions + ) -> ContainsPointsResult { + #expect(cacheKey == nil) + #expect(options.isEmpty) + var mask: BitVector64 = [] + for (index, point) in points.enumerated() { + mask[index] = CGRect(x: 0, y: 0, width: 10, height: 10).contains(point) + } + return ContainsPointsResult(mask: mask, priority: 0, children: []) + } + + override func resetGesture() { + resetCount += 1 + } + + private struct Phase: Rule { + let responder: TestResponder + @Attribute var events: [EventID: any EventType] + @Attribute var resetSeed: UInt32 + + var value: GesturePhase { + responder.events = events + responder.resetSeed = resetSeed + return responder.phase + } + } +} + +private final class TestHost: ViewRendererHost, EventGraphHost { + let viewGraph: ViewGraph + let eventBindingManager = EventBindingManager() + var propertiesNeedingUpdate: ViewRendererHostProperties = [] + var renderingPhase: ViewRenderingPhase = .none + var externalUpdateCount = 0 + var currentTimestamp = Time.zero + + init(viewGraph: ViewGraph) { + self.viewGraph = viewGraph + } + + func `as`(_ type: T.Type) -> T? { self as? T } + func updateViewGraph(body: (ViewGraph) -> T) -> T { body(viewGraph) } + func requestUpdate(after: Double) {} + func updateRootView() {} + func updateEnvironment() {} + func updateSize() {} + func updateSafeArea() {} + func updateContainerSize() {} + + var responderNode: ResponderNode? { nil } + var focusedResponder: ResponderNode? { nil } + var nextGestureUpdateTime: Time { .infinity } + func setInheritedPhase(_ phase: _GestureInputs.InheritedPhase) {} + func sendEvents( + _ events: [EventID: any EventType], + rootNode: ResponderNode, + at time: Time + ) -> GesturePhase { .failed } + func resetEvents() {} + func gestureCategory() -> GestureCategory? { nil } +} diff --git a/Tests/OpenSwiftUICoreTests/Event/Responder/HitTestBindingModifierTests.swift b/Tests/OpenSwiftUICoreTests/Event/Responder/HitTestBindingModifierTests.swift index 8746e0f8e..3863b6441 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Responder/HitTestBindingModifierTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Responder/HitTestBindingModifierTests.swift @@ -11,10 +11,11 @@ import OpenAttributeGraphShims @_private(sourceFile: "HitTestBindingModifier.swift") #endif import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing @MainActor -@Suite(.disabled(if: attributeGraphVendor == .oag)) +@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc)) struct HitTestBindingModifierTests { // MARK: - Point Sampling diff --git a/Tests/OpenSwiftUICoreTests/Event/Responder/MultiViewResponderTests.swift b/Tests/OpenSwiftUICoreTests/Event/Responder/MultiViewResponderTests.swift index 330778560..23c4bbcdb 100644 --- a/Tests/OpenSwiftUICoreTests/Event/Responder/MultiViewResponderTests.swift +++ b/Tests/OpenSwiftUICoreTests/Event/Responder/MultiViewResponderTests.swift @@ -6,10 +6,11 @@ import OpenAttributeGraphShims @_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing @MainActor -@Suite(.disabled(if: attributeGraphVendor == .oag)) +@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc)) struct MultiViewResponderTests { @Test func unchangedChildrenKeepObserverUntilNextChange() { diff --git a/Tests/OpenSwiftUISymbolDualTests/Event/Gesture/GestureDebugDualTests.swift b/Tests/OpenSwiftUISymbolDualTests/Event/Gesture/GestureDebugDualTests.swift index 926070a52..438ad8765 100644 --- a/Tests/OpenSwiftUISymbolDualTests/Event/Gesture/GestureDebugDualTests.swift +++ b/Tests/OpenSwiftUISymbolDualTests/Event/Gesture/GestureDebugDualTests.swift @@ -6,6 +6,7 @@ import Foundation @_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore +import OpenSwiftUITestsSupport import OSLog import Testing @@ -226,7 +227,7 @@ private func makeData( } @MainActor -@Suite(.disabled(if: isX86_64, "OSLogStore does not reliably return current-process log entries on x86_64 simulator.")) +@Suite(.disabled(if: isX86_64, "OSLogStore does not reliably return current-process log entries on x86_64 simulator."), .tags(.aigc)) struct GestureDebugDualTests { // NOTE: entry.date has some range diff. So we can't use $0.date > date. Use count instead. @available(iOS 15, macOS 12, *) diff --git a/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift b/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift index ec4fbfc32..50b920dc1 100644 --- a/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift +++ b/Tests/OpenSwiftUITests/Event/InputEvent/KeyPressTests.swift @@ -7,10 +7,11 @@ import OpenAttributeGraphShims @_spi(_) @testable import OpenSwiftUI @_spi(ForOpenSwiftUIOnly) import OpenSwiftUICore +import OpenSwiftUITestsSupport import Testing @MainActor -@Suite(.disabled(if: attributeGraphVendor == .oag)) +@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc)) struct KeyPressTests { @Test(arguments: [ (KeyPress.Phases(), 0, "[]"),