diff --git a/Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift b/Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift index c08aa03..bfedc02 100644 --- a/Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift +++ b/Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift @@ -72,4 +72,8 @@ public extension PathProperties { var animated: Bool { self["animated"] as? Bool ?? true } + + internal var historicalLocation: Bool { + self["historical_location"] as? Bool ?? false + } } diff --git a/Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift b/Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift index 1557f3f..942926a 100644 --- a/Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift +++ b/Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift @@ -28,4 +28,8 @@ public extension VisitProposal { var animated: Bool { properties.animated } + + internal var isHistoricalLocation: Bool { + properties.historicalLocation + } } diff --git a/Source/Turbo/Navigator/NavigationHierarchyController.swift b/Source/Turbo/Navigator/NavigationHierarchyController.swift index bf4d134..0fe4c04 100644 --- a/Source/Turbo/Navigator/NavigationHierarchyController.swift +++ b/Source/Turbo/Navigator/NavigationHierarchyController.swift @@ -41,6 +41,8 @@ class NavigationHierarchyController { visitable.visitableView.allowsPullToRefresh = proposal.pullToRefreshEnabled } + dismissModalIfNeeded(for: proposal) + switch proposal.presentation { case .default: navigate(with: controller, via: proposal) @@ -175,6 +177,11 @@ class NavigationHierarchyController { } private func refresh(via proposal: VisitProposal) { + if proposal.isHistoricalLocation { + refreshIfTopViewControllerIsVisitable(from: .main) + return + } + if navigationController.presentedViewController != nil { if modalNavigationController.viewControllers.count == 1 { navigationController.dismiss(animated: proposal.animated) @@ -183,10 +190,11 @@ class NavigationHierarchyController { modalNavigationController.popViewController(animated: proposal.animated) refreshIfTopViewControllerIsVisitable(from: .modal) } - } else { - navigationController.popViewController(animated: proposal.animated) - refreshIfTopViewControllerIsVisitable(from: .main) + return } + + navigationController.popViewController(animated: proposal.animated) + refreshIfTopViewControllerIsVisitable(from: .main) } private func replaceRoot(with controller: UIViewController, via proposal: VisitProposal) { @@ -204,4 +212,13 @@ class NavigationHierarchyController { newTopmostVisitable: navControllerTopmostVisitable) } } + + private func dismissModalIfNeeded(for visit: VisitProposal) { + // The desired behaviour for historical location visits is + // to always dismiss the "modal" stack. + let dismissModal = visit.isHistoricalLocation && navigationController.presentedViewController != nil + guard dismissModal else { return } + + navigationController.dismiss(animated: visit.animated) + } } diff --git a/Source/Turbo/Path Configuration/PathConfiguration.swift b/Source/Turbo/Path Configuration/PathConfiguration.swift index 4eb388a..e89c41c 100644 --- a/Source/Turbo/Path Configuration/PathConfiguration.swift +++ b/Source/Turbo/Path Configuration/PathConfiguration.swift @@ -28,7 +28,8 @@ public final class PathConfiguration { public private(set) var settings: [String: AnyHashable] = [:] /// The list of rules from the configuration: `{ rules: [] }` - public private(set) var rules: [PathRule] = [] + /// Default server route rules are included by default. + public private(set) var rules: [PathRule] = PathRule.defaultServerRoutes /// Sources for this configuration, setting it will /// cause the configuration to be loaded from the new sources @@ -93,6 +94,8 @@ public final class PathConfiguration { // Update our internal state with the config from the loader settings = config.settings rules = config.rules + // Always include the default server route rules. + rules.append(contentsOf: PathRule.defaultServerRoutes) delegate?.pathConfigurationDidUpdate() } } diff --git a/Source/Turbo/Path Configuration/PathRule+ServerRoutes.swift b/Source/Turbo/Path Configuration/PathRule+ServerRoutes.swift new file mode 100644 index 0000000..e650fa6 --- /dev/null +++ b/Source/Turbo/Path Configuration/PathRule+ServerRoutes.swift @@ -0,0 +1,36 @@ +import Foundation + +extension PathRule { + static let defaultServerRoutes: [PathRule] = [ + .recedeHistoricalLocation, + .resumeHistoricalLocation, + .refreshHistoricalLocation + ] + + static let recedeHistoricalLocation = PathRule( + patterns: ["/recede_historical_location"], + properties: [ + "presentation": "pop", + "context": "default", + "historical_location": true + ] + ) + + static let resumeHistoricalLocation = PathRule( + patterns: ["/resume_historical_location"], + properties: [ + "presentation": "none", + "context": "default", + "historical_location": true + ] + ) + + static let refreshHistoricalLocation = PathRule( + patterns: ["/refresh_historical_location"], + properties: [ + "presentation": "refresh", + "context": "default", + "historical_location": true + ] + ) +} diff --git a/Tests/Turbo/Fixtures/test-configuration-historical-locations.json b/Tests/Turbo/Fixtures/test-configuration-historical-locations.json new file mode 100644 index 0000000..3f2f64a --- /dev/null +++ b/Tests/Turbo/Fixtures/test-configuration-historical-locations.json @@ -0,0 +1,31 @@ +{ + "rules":[ + { + "patterns":[ + "/recede_historical_location" + ], + "properties":{ + "presentation":"pop", + "context":"modal" + } + }, + { + "patterns":[ + "/resume_historical_location" + ], + "properties":{ + "presentation":"refresh", + "context":"modal" + } + }, + { + "patterns":[ + "/refresh_historical_location" + ], + "properties":{ + "presentation":"pop", + "context":"modal" + } + } + ] +} \ No newline at end of file diff --git a/Tests/Turbo/Navigator/NavigationHierarchyControllerHistoricalLocationTests.swift b/Tests/Turbo/Navigator/NavigationHierarchyControllerHistoricalLocationTests.swift new file mode 100644 index 0000000..366256f --- /dev/null +++ b/Tests/Turbo/Navigator/NavigationHierarchyControllerHistoricalLocationTests.swift @@ -0,0 +1,148 @@ +@testable import HotwireNative +import XCTest + +@MainActor +final class NavigationHierarchyControllerHistoricalLocationTests: XCTestCase { + override func setUp() { + navigationController = TestableNavigationController() + modalNavigationController = TestableNavigationController() + + navigator = Navigator(session: session, modalSession: modalSession) + hierarchyController = NavigationHierarchyController( + delegate: navigator, + navigationController: navigationController, + modalNavigationController: modalNavigationController + ) + navigator.hierarchyController = hierarchyController + + loadNavigationControllerInWindow() + } + + // Resume behaviour: + // 1. Dismiss the modal view controller. + // 2. Arrive back at the view controller on the "default" stack. + func test_resumeHistoricalLocation() async throws { + let defaultOne = VisitProposal(path: "/default_one", context: .default) + navigator.route(defaultOne) + try await delay() + + let defaultTwo = VisitProposal(path: "/default_two", context: .default) + navigator.route(defaultTwo) + try await delay() + + XCTAssertEqual(navigationController.viewControllers.count, 2) + + let modalOne = VisitProposal(path: "/modal_one", context: .modal) + navigator.route(modalOne) + + try await delay() + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + // Reset spy's properties. + session.visitWasCalled = false + session.visitAction = nil + + let resumeHistoricalLocationProposal = VisitProposal( + path: PathRule.resumeHistoricalLocation.patterns.first!, + additionalProperties: PathRule.resumeHistoricalLocation.properties + ) + navigator.route(resumeHistoricalLocationProposal) + try await delay() + + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, defaultTwo.url) + XCTAssertFalse(session.visitWasCalled) + } + + // Refresh behaviour: + // 1. Dismiss the modal view controller. + // 2. Arrive back at the view controller on the "default" stack. + // 3. Refresh the view controller on the "default" stack by revisiting the location. + func test_refreshHistoricalLocation() async throws { + let defaultOne = VisitProposal(path: "/default_one", context: .default) + navigator.route(defaultOne) + try await delay() + + XCTAssertEqual(navigationController.viewControllers.count, 1) + + let modalOne = VisitProposal(path: "/modal_one", context: .modal) + navigator.route(modalOne) + try await delay() + + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + // Reset spy's properties. + session.visitWasCalled = false + session.visitAction = nil + + let refreshHistoricalLocationProposal = VisitProposal( + path: PathRule.refreshHistoricalLocation.patterns.first!, + additionalProperties: PathRule.refreshHistoricalLocation.properties + ) + navigator.route(refreshHistoricalLocationProposal) + try await delay() + + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, defaultOne.url) + XCTAssertTrue(session.visitWasCalled) + XCTAssertEqual(session.visitAction, .restore) + } + + // Recede behaviour: + // 1. Dismiss the modal view controller. + // 2. Arrive back at the view controller on the "default" stack. + // 3. Pop the view controller on the "default" stack (unless it's already at the beginning of the backstack). + // 4. This will trigger a refresh on the appeared view controller if the snapshot cache has been cleared by a form submission. + @MainActor + func test_recedeHistoricalLocation() async throws { + let defaultOne = VisitProposal(path: "/default_one", context: .default) + navigator.route(defaultOne) + try await delay() + + let defaultTwo = VisitProposal(path: "/default_two", context: .default) + navigator.route(defaultTwo) + try await delay() + + XCTAssertEqual(navigationController.viewControllers.count, 2) + + let modalOne = VisitProposal(path: "/modal_one", context: .modal) + navigator.route(modalOne) + try await delay() + + XCTAssertEqual(modalNavigationController.viewControllers.count, 1) + + let recedeHistoricalLocationProposal = VisitProposal( + path: PathRule.recedeHistoricalLocation.patterns.first!, + additionalProperties: PathRule.recedeHistoricalLocation.properties + ) + navigator.route(recedeHistoricalLocationProposal) + try await delay() + + XCTAssertNil(navigationController.presentedViewController) + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssertEqual(navigator.session.activeVisitable?.visitableURL, defaultOne.url) + } + + func delay() async throws { + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second + } + + private let session = SessionSpy(webView: Hotwire.config.makeWebView()) + private let modalSession = Session(webView: Hotwire.config.makeWebView()) + + private var navigator: Navigator! + private var hierarchyController: NavigationHierarchyController! + private var navigationController: TestableNavigationController! + private var modalNavigationController: TestableNavigationController! + + private let window = UIWindow() + + // Simulate a "real" app so presenting view controllers works under test. + private func loadNavigationControllerInWindow() { + window.rootViewController = navigationController + window.makeKeyAndVisible() + navigationController.loadViewIfNeeded() + } +} diff --git a/Tests/Turbo/Navigator/NavigationHierarchyControllerTests.swift b/Tests/Turbo/Navigator/NavigationHierarchyControllerTests.swift index 8ede16a..c9e1d89 100644 --- a/Tests/Turbo/Navigator/NavigationHierarchyControllerTests.swift +++ b/Tests/Turbo/Navigator/NavigationHierarchyControllerTests.swift @@ -429,7 +429,7 @@ private class EmptyNavigationDelegate: NavigationHierarchyControllerDelegate { // MARK: - VisitProposal extension -private extension VisitProposal { +extension VisitProposal { init(path: String = "", action: VisitAction = .advance, context: Navigation.Context = .default, @@ -441,7 +441,7 @@ private extension VisitProposal { "context": context.rawValue, "presentation": presentation.rawValue ] - let properties = defaultProperties.merging(additionalProperties) { (current, _) in current } + let properties = defaultProperties.merging(additionalProperties) { (_, new) in new } self.init(url: url, options: options, properties: properties) } diff --git a/Tests/Turbo/Navigator/TestableNavigationController.swift b/Tests/Turbo/Navigator/TestableNavigationController.swift index e2a01d6..83d7ac7 100644 --- a/Tests/Turbo/Navigator/TestableNavigationController.swift +++ b/Tests/Turbo/Navigator/TestableNavigationController.swift @@ -1,9 +1,10 @@ import UIKit +@testable import HotwireNative /// Manipulate a navigation controller under test. /// Ensures `viewControllers` is updated synchronously. /// Manages `presentedViewController` directly because it isn't updated on the same thread. -class TestableNavigationController: UINavigationController { +class TestableNavigationController: HotwireNavigationController { override var presentedViewController: UIViewController? { get { _presentedViewController } set { _presentedViewController = newValue } diff --git a/Tests/Turbo/PathConfigurationTests.swift b/Tests/Turbo/PathConfigurationTests.swift index 9e0975b..f10641b 100644 --- a/Tests/Turbo/PathConfigurationTests.swift +++ b/Tests/Turbo/PathConfigurationTests.swift @@ -6,16 +6,55 @@ class PathConfigurationTests: XCTestCase { var configuration: PathConfiguration! override func setUp() { - configuration = PathConfiguration(sources: [.file(fileURL)]) - XCTAssertGreaterThan(configuration.rules.count, 0) + configuration = PathConfiguration() + } + + func test_initWithNoSourcesSetsDefaultRules() { + // Default three historical location rules are always added by default. + XCTAssertEqual(configuration.rules.count, PathRule.defaultServerRoutes.count) + + for rule in PathRule.defaultServerRoutes { + XCTAssertNotNil(configuration.properties(for: rule.patterns.first!)) + } } func test_init_automaticallyLoadsTheConfigurationFromTheSpecifiedLocation() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.settings.count, 2) - XCTAssertEqual(configuration.rules.count, 5) + // Default three historical location rules are always added by default. + XCTAssertEqual(configuration.rules.count, 5 + PathRule.defaultServerRoutes.count) + + for rule in PathRule.defaultServerRoutes { + XCTAssertNotNil(configuration.properties(for: rule.patterns.first!)) + } + } + + func test_loadingPathConfigWithHistoricalLocationRulesDoesNotOverrideTheDefaults() { + let fileURL = Bundle.module.url( + forResource: "test-configuration-historical-locations", + withExtension: "json", + subdirectory: "Fixtures")! + configuration.sources = [.file(fileURL)] + + XCTAssertEqual(configuration.rules.count, 3 + PathRule.defaultServerRoutes.count) + + let recedeProperties = configuration.properties(for: "/recede_historical_location") + XCTAssertEqual(recedeProperties.context, .default) + XCTAssertEqual(recedeProperties.presentation, .pop) + + let refreshProperties = configuration.properties(for: "/refresh_historical_location") + XCTAssertEqual(refreshProperties.context, .default) + XCTAssertEqual(refreshProperties.presentation, .refresh) + + let resumeProperties = configuration.properties(for: "/resume_historical_location") + XCTAssertEqual(resumeProperties.context, .default) + XCTAssertEqual(resumeProperties.presentation, .none) } func test_settings_returnsCurrentSettings() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.settings, [ "some-feature-enabled": true, "server": "beta" @@ -23,12 +62,16 @@ class PathConfigurationTests: XCTestCase { } func test_propertiesForPath_whenPathMatches_returnsProperties() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.properties(for: "/"), [ "page": "root" ]) } func test_propertiesForPath_whenPathMatchesMultipleRules_mergesProperties() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.properties(for: "/new"), [ "context": "modal", "background_color": "black" @@ -41,6 +84,8 @@ class PathConfigurationTests: XCTestCase { } func test_propertiesForURL_withParams() { + loadConfigurationFromFile() + let url = URL(string: "http://turbo.test/sample.pdf?open_in_external_browser=true")! Hotwire.config.pathConfiguration.matchQueryStrings = false @@ -53,16 +98,24 @@ class PathConfigurationTests: XCTestCase { } func test_propertiesForPath_whenNoMatch_returnsEmptyProperties() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.properties(for: "/missing"), [:]) } func test_subscript_isAConvenienceMethodForPropertiesForPath() { + loadConfigurationFromFile() + XCTAssertEqual(configuration.properties(for: "/new"), configuration["/new"]) XCTAssertEqual(configuration.properties(for: "/edit"), configuration["/edit"]) XCTAssertEqual(configuration.properties(for: "/"), configuration["/"]) XCTAssertEqual(configuration.properties(for: "/missing"), configuration["/missing"]) XCTAssertEqual(configuration.properties(for: "/sample.pdf?open_in_external_browser=true"), configuration["/sample.pdf?open_in_external_browser=true"]) } + + func loadConfigurationFromFile() { + configuration.sources = [.file(fileURL)] + } } class PathConfigTests: XCTestCase { diff --git a/Tests/Turbo/PathRuleTests.swift b/Tests/Turbo/PathRuleTests.swift index a9dc364..c59ebb8 100644 --- a/Tests/Turbo/PathRuleTests.swift +++ b/Tests/Turbo/PathRuleTests.swift @@ -27,4 +27,41 @@ class PathRuleTests: XCTestCase { XCTAssertFalse(rule.match(path: "/new")) XCTAssertFalse(rule.match(path: "foo")) } + + func test_recedeHistoricalLocation() { + let rule = PathRule.recedeHistoricalLocation + XCTAssertEqual(rule.patterns, ["/recede_historical_location"]) + XCTAssertEqual(rule.properties, ["presentation": "pop", + "context": "default", + "historical_location": true]) + } + + func test_refreshHistoricalLocation() { + let rule = PathRule.refreshHistoricalLocation + XCTAssertEqual(rule.patterns, ["/refresh_historical_location"]) + XCTAssertEqual(rule.properties, ["presentation": "refresh", + "context": "default", + "historical_location": true]) + } + + func test_resumeHistoricalLocation() { + let rule = PathRule.resumeHistoricalLocation + XCTAssertEqual(rule.patterns, ["/resume_historical_location"]) + XCTAssertEqual(rule.properties, ["presentation": "none", + "context": "default", + "historical_location": true]) + } + + func test_defaultHistoricalLocationRules() { + XCTAssertEqual(PathRule.defaultServerRoutes.count, 3) + let expectedRules: [PathRule] = [ + PathRule.recedeHistoricalLocation, + PathRule.resumeHistoricalLocation, + PathRule.refreshHistoricalLocation + ] + + if #available(iOS 16.0, *) { + XCTAssertTrue(PathRule.defaultServerRoutes.contains(expectedRules)) + } + } } diff --git a/Tests/Turbo/Spies/SessionSpy.swift b/Tests/Turbo/Spies/SessionSpy.swift new file mode 100644 index 0000000..b46abd6 --- /dev/null +++ b/Tests/Turbo/Spies/SessionSpy.swift @@ -0,0 +1,12 @@ +@testable import HotwireNative + +final class SessionSpy: Session { + var visitWasCalled = false + var visitAction: VisitAction? + + override func visit(_ visitable: any Visitable, action: VisitAction) { + visitWasCalled = true + visitAction = action + super.visit(visitable, action: action) + } +}