Separate AppContext for previews and UI testing (#961)

Clarify the use of contexts:

- **Production** (.shared)
- **Previews** (.mock → .forPreviews)
  - ONLY use it in UILibrary for, well, previews
  - This context has dumb profiles with UUIDs as names
  - Registry is fake
- **UI Tests** (.forUITesting)
  - Add new context for UI testing
  - Selected based on command line arguments
  - This context has mock data tuned for decent screenshots
  - Registry is real

Share the same InAppProcessor in .shared and .forTesting contexts
because the app behavior was inconsistent regarding e.g. in-app
purchases.
This commit is contained in:
Davide 2024-11-28 17:31:17 +01:00 committed by GitHub
parent 962361cb9f
commit 2a467e0c7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 251 additions and 139 deletions

View File

@ -16,6 +16,8 @@
0E483E812CE64D6B00584B32 /* Shared+Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */; }; 0E483E812CE64D6B00584B32 /* Shared+Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */; };
0E483E842CE6501100584B32 /* Shared+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E822CE6501100584B32 /* Shared+App.swift */; }; 0E483E842CE6501100584B32 /* Shared+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E822CE6501100584B32 /* Shared+App.swift */; };
0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; }; 0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; };
0E6EEEE32CF8CABA0076E2B0 /* AppContext+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6EEEE12CF8CABA0076E2B0 /* AppContext+Testing.swift */; };
0E6EEEE42CF8CABA0076E2B0 /* ProfileManager+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6EEEE22CF8CABA0076E2B0 /* ProfileManager+Testing.swift */; };
0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; }; 0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; };
0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0E757F232CD0D2BD006E13E1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */; }; 0E757F232CD0D2BD006E13E1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F212CD0D2B7006E13E1 /* AppDelegate.swift */; };
@ -134,6 +136,8 @@
0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+Tunnel.swift"; sourceTree = "<group>"; }; 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+Tunnel.swift"; sourceTree = "<group>"; };
0E483E822CE6501100584B32 /* Shared+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+App.swift"; sourceTree = "<group>"; }; 0E483E822CE6501100584B32 /* Shared+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+App.swift"; sourceTree = "<group>"; };
0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = "<group>"; }; 0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = "<group>"; };
0E6EEEE12CF8CABA0076E2B0 /* AppContext+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+Testing.swift"; sourceTree = "<group>"; };
0E6EEEE22CF8CABA0076E2B0 /* ProfileManager+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileManager+Testing.swift"; sourceTree = "<group>"; };
0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; };
0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = "<group>"; }; 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = "<group>"; };
0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = "<group>"; }; 0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = "<group>"; };
@ -277,6 +281,15 @@
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0E6EEEE62CF8CB090076E2B0 /* Testing */ = {
isa = PBXGroup;
children = (
0E6EEEE12CF8CABA0076E2B0 /* AppContext+Testing.swift */,
0E6EEEE22CF8CABA0076E2B0 /* ProfileManager+Testing.swift */,
);
path = Testing;
sourceTree = "<group>";
};
0E757F112CD0CFFC006E13E1 /* LoginItem */ = { 0E757F112CD0CFFC006E13E1 /* LoginItem */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -323,6 +336,7 @@
0E7E3D612B9345FD002BBDB4 /* Shared */ = { 0E7E3D612B9345FD002BBDB4 /* Shared */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0E6EEEE62CF8CB090076E2B0 /* Testing */,
0EC797402B9378E000C093B7 /* AppContext+Shared.swift */, 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */,
0EC797412B9378E000C093B7 /* Shared.swift */, 0EC797412B9378E000C093B7 /* Shared.swift */,
0E483E822CE6501100584B32 /* Shared+App.swift */, 0E483E822CE6501100584B32 /* Shared+App.swift */,
@ -673,6 +687,8 @@
0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */, 0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */,
0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */, 0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */,
0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */, 0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */,
0E6EEEE32CF8CABA0076E2B0 /* AppContext+Testing.swift in Sources */,
0E6EEEE42CF8CABA0076E2B0 /* ProfileManager+Testing.swift in Sources */,
0E483E842CE6501100584B32 /* Shared+App.swift in Sources */, 0E483E842CE6501100584B32 /* Shared+App.swift in Sources */,
0EC797432B9378E000C093B7 /* Shared.swift in Sources */, 0EC797432B9378E000C093B7 /* Shared.swift in Sources */,
); );

View File

@ -32,9 +32,9 @@ import UITesting
@MainActor @MainActor
final class AppDelegate: NSObject { final class AppDelegate: NSObject {
let context: AppContext = { let context: AppContext = {
guard !AppCommandLine.contains(.uiTesting) else { if AppCommandLine.contains(.uiTesting) {
pp_log(.app, .info, "UI tests: mock AppContext") pp_log(.app, .info, "UI tests: mock AppContext")
return .mock(withRegistry: .shared) return .forUITesting(withRegistry: .shared)
} }
return .shared return .shared
}() }()

View File

@ -133,6 +133,20 @@
ReferencedContainer = "container:"> ReferencedContainer = "container:">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "UITesting"
BuildableName = "UITesting"
BlueprintName = "UITesting"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction

View File

@ -165,8 +165,8 @@ extension AboutCoordinator {
#Preview { #Preview {
AboutCoordinator( AboutCoordinator(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock tunnel: .forPreviews
) )
.withMockEnvironment() .withMockEnvironment()
} }

View File

@ -270,7 +270,7 @@ extension AppCoordinator {
} }
var overriddenLayout: ProfilesLayout { var overriddenLayout: ProfilesLayout {
guard !isUITesting else { if isUITesting {
return isBigDevice ? .grid : .list return isBigDevice ? .grid : .list
} }
return layout return layout
@ -306,8 +306,8 @@ extension AppCoordinator {
#Preview { #Preview {
AppCoordinator( AppCoordinator(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
registry: Registry() registry: Registry()
) )
.withMockEnvironment() .withMockEnvironment()

View File

@ -110,7 +110,7 @@ private extension AppToolbar {
Text("AppToolbar") Text("AppToolbar")
.toolbar { .toolbar {
AppToolbar( AppToolbar(
profileManager: .mock, profileManager: .forPreviews,
registry: Registry(), registry: Registry(),
layout: .constant(.list), layout: .constant(.list),
isImporting: .constant(false), isImporting: .constant(false),

View File

@ -123,7 +123,7 @@ private extension InstalledProfileView {
style: .installedProfile, style: .installedProfile,
profileManager: profileManager, profileManager: profileManager,
tunnel: tunnel, tunnel: tunnel,
preview: .init(profile ?? .mock), preview: .init(profile ?? .forPreviews),
interactiveManager: interactiveManager, interactiveManager: interactiveManager,
errorHandler: errorHandler, errorHandler: errorHandler,
flow: flow flow: flow
@ -298,9 +298,9 @@ private struct HeaderView: View {
var body: some View { var body: some View {
InstalledProfileView( InstalledProfileView(
layout: layout, layout: layout,
profileManager: .mock, profileManager: .forPreviews,
profile: .mock, profile: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),
nextProfileId: .constant(nil) nextProfileId: .constant(nil)
@ -313,9 +313,9 @@ private struct ContentView: View {
ForEach(0..<3) { _ in ForEach(0..<3) { _ in
ProfileRowView( ProfileRowView(
style: .full, style: .full,
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
preview: .init(.mock), preview: .init(.forPreviews),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),
nextProfileId: .constant(nil), nextProfileId: .constant(nil),

View File

@ -70,7 +70,7 @@ struct OnboardingModifier: ViewModifier {
private extension OnboardingModifier { private extension OnboardingModifier {
func advance() { func advance() {
guard !isUITesting else { if isUITesting {
pp_log(.app, .info, "UI tests: skip onboarding") pp_log(.app, .info, "UI tests: skip onboarding")
return return
} }

View File

@ -67,13 +67,13 @@ struct ProfileCardView: View {
Section { Section {
ProfileCardView( ProfileCardView(
style: .compact, style: .compact,
preview: .init(.mock) preview: .init(.forPreviews)
) )
} }
Section { Section {
ProfileCardView( ProfileCardView(
style: .full, style: .full,
preview: .init(.mock) preview: .init(.forPreviews)
) )
} }
} }

View File

@ -157,8 +157,8 @@ private struct PreviewView: View {
NavigationStack { NavigationStack {
ProfileContainerView( ProfileContainerView(
layout: layout, layout: layout,
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
registry: Registry(), registry: Registry(),
isImporting: .constant(false), isImporting: .constant(false),
errorHandler: .default() errorHandler: .default()

View File

@ -158,9 +158,9 @@ private extension ProfileContextMenu {
Menu("Menu") { Menu("Menu") {
ProfileContextMenu( ProfileContextMenu(
style: .installedProfile, style: .installedProfile,
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
preview: .init(.mock), preview: .init(.forPreviews),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default() errorHandler: .default()
) )

View File

@ -150,8 +150,8 @@ private extension ProfileGridView {
#Preview { #Preview {
ProfileGridView( ProfileGridView(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default() errorHandler: .default()
) )

View File

@ -149,8 +149,8 @@ private extension ProfileListView {
#Preview { #Preview {
ProfileListView( ProfileListView(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default() errorHandler: .default()
) )

View File

@ -205,14 +205,14 @@ private extension ProfileRowView {
// MARK: - Previews // MARK: - Previews
#Preview { #Preview {
let profile: Profile = .mock let profile: Profile = .forPreviews
let profileManager: ProfileManager = .mock let profileManager: ProfileManager = .forPreviews
return Form { return Form {
ProfileRowView( ProfileRowView(
style: .compact, style: .compact,
profileManager: profileManager, profileManager: profileManager,
tunnel: .mock, tunnel: .forPreviews,
preview: .init(profile), preview: .init(profile),
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default(), errorHandler: .default(),

View File

@ -187,7 +187,7 @@ private extension DiagnosticsView {
} }
#Preview { #Preview {
DiagnosticsView(profileManager: .mock, tunnel: .mock) { DiagnosticsView(profileManager: .forPreviews, tunnel: .forPreviews) {
[ [
.init(date: Date(), url: URL(string: "http://one.com")!), .init(date: Date(), url: URL(string: "http://one.com")!),
.init(date: Date().addingTimeInterval(-60), url: URL(string: "http://two.com")!), .init(date: Date().addingTimeInterval(-60), url: URL(string: "http://two.com")!),

View File

@ -60,8 +60,8 @@ private extension ModuleDetailView {
#Preview { #Preview {
ModuleDetailView( ModuleDetailView(
profileEditor: ProfileEditor(profile: .mock), profileEditor: ProfileEditor(profile: .forPreviews),
moduleId: Profile.mock.modules.first?.id, moduleId: Profile.forPreviews.modules.first?.id,
moduleViewFactory: DefaultModuleViewFactory(registry: Registry()) moduleViewFactory: DefaultModuleViewFactory(registry: Registry())
) )
.withMockEnvironment() .withMockEnvironment()

View File

@ -164,7 +164,7 @@ private extension ProfileCoordinator {
#Preview { #Preview {
ProfileCoordinator( ProfileCoordinator(
profileManager: .mock, profileManager: .forPreviews,
profileEditor: ProfileEditor(profile: .newMockProfile()), profileEditor: ProfileEditor(profile: .newMockProfile()),
initialModuleId: nil, initialModuleId: nil,
registry: Registry(), registry: Registry(),

View File

@ -147,7 +147,7 @@ private extension ModuleListView {
#Preview { #Preview {
ModuleListView( ModuleListView(
profileEditor: ProfileEditor(profile: .mock), profileEditor: ProfileEditor(profile: .forPreviews),
selectedModuleId: .constant(nil), selectedModuleId: .constant(nil),
errorModuleIds: .constant([]), errorModuleIds: .constant([]),
paywallReason: .constant(nil) paywallReason: .constant(nil)

View File

@ -136,8 +136,8 @@ private extension AppCoordinator {
#Preview { #Preview {
AppCoordinator( AppCoordinator(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
registry: Registry() registry: Registry()
) )
.withMockEnvironment() .withMockEnvironment()

View File

@ -231,7 +231,7 @@ private extension ActiveProfileView {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.task { .task {
try? await ProviderManager.mock.fetchIndex(from: [API.bundled]) try? await ProviderManager.forPreviews.fetchIndex(from: [API.bundled])
} }
} }
@ -247,7 +247,7 @@ private struct ContentPreview: View {
var body: some View { var body: some View {
ActiveProfileView( ActiveProfileView(
profile: profile, profile: profile,
tunnel: .mock, tunnel: .forPreviews,
isSwitching: $isSwitching, isSwitching: $isSwitching,
focusedField: $focusedField, focusedField: $focusedField,
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),

View File

@ -115,7 +115,7 @@ private extension ProfileListView {
// MARK: - Previews // MARK: - Previews
#Preview("List") { #Preview("List") {
ContentPreview(profileManager: .mock) ContentPreview(profileManager: .forPreviews)
} }
#Preview("Empty") { #Preview("Empty") {
@ -131,7 +131,7 @@ private struct ContentPreview: View {
var body: some View { var body: some View {
ProfileListView( ProfileListView(
profileManager: profileManager, profileManager: profileManager,
tunnel: .mock, tunnel: .forPreviews,
focusedField: $focusedField, focusedField: $focusedField,
interactiveManager: InteractiveManager(), interactiveManager: InteractiveManager(),
errorHandler: .default() errorHandler: .default()

View File

@ -190,8 +190,8 @@ private extension ProfileView {
#Preview("List") { #Preview("List") {
ProfileView( ProfileView(
profileManager: .mock, profileManager: .forPreviews,
tunnel: .mock, tunnel: .forPreviews,
errorHandler: .default(), errorHandler: .default(),
showsSidePanel: true showsSidePanel: true
) )
@ -201,7 +201,7 @@ private extension ProfileView {
#Preview("Empty") { #Preview("Empty") {
ProfileView( ProfileView(
profileManager: ProfileManager(profiles: []), profileManager: ProfileManager(profiles: []),
tunnel: .mock, tunnel: .forPreviews,
errorHandler: .default(), errorHandler: .default(),
showsSidePanel: true showsSidePanel: true
) )

View File

@ -145,7 +145,7 @@ private struct DetailView: View {
// MARK: - // MARK: -
#Preview { #Preview {
SettingsView(tunnel: .mock) SettingsView(tunnel: .forPreviews)
.themeNavigationStack() .themeNavigationStack()
.withMockEnvironment() .withMockEnvironment()
} }

View File

@ -174,7 +174,7 @@ extension ProfileManager {
return return
} }
requiredFeatures = allProfiles.reduce(into: [:]) { requiredFeatures = allProfiles.reduce(into: [:]) {
guard let ineligible = processor.verify($1.value), !ineligible.isEmpty else { guard let ineligible = processor.requiredFeatures($1.value), !ineligible.isEmpty else {
return return
} }
$0[$1.key] = ineligible $0[$1.key] = ineligible

View File

@ -35,7 +35,7 @@ public final class InAppProcessor: ObservableObject, Sendable {
private nonisolated let _preview: (Profile) -> ProfilePreview private nonisolated let _preview: (Profile) -> ProfilePreview
private nonisolated let _verify: (IAPManager, Profile) -> Set<AppFeature>? private nonisolated let _requiredFeatures: (IAPManager, Profile) -> Set<AppFeature>?
private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder private nonisolated let _willRebuild: (IAPManager, Profile.Builder) throws -> Profile.Builder
@ -46,7 +46,7 @@ public final class InAppProcessor: ObservableObject, Sendable {
title: @escaping (Profile) -> String, title: @escaping (Profile) -> String,
isIncluded: @escaping (IAPManager, Profile) -> Bool, isIncluded: @escaping (IAPManager, Profile) -> Bool,
preview: @escaping (Profile) -> ProfilePreview, preview: @escaping (Profile) -> ProfilePreview,
verify: @escaping (IAPManager, Profile) -> Set<AppFeature>?, requiredFeatures: @escaping (IAPManager, Profile) -> Set<AppFeature>?,
willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, willRebuild: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder,
willInstall: @escaping (IAPManager, Profile) throws -> Profile willInstall: @escaping (IAPManager, Profile) throws -> Profile
) { ) {
@ -54,7 +54,7 @@ public final class InAppProcessor: ObservableObject, Sendable {
_title = title _title = title
_isIncluded = isIncluded _isIncluded = isIncluded
_preview = preview _preview = preview
_verify = verify _requiredFeatures = requiredFeatures
_willRebuild = willRebuild _willRebuild = willRebuild
_willInstall = willInstall _willInstall = willInstall
} }
@ -75,8 +75,8 @@ extension InAppProcessor: ProfileProcessor {
_preview(profile) _preview(profile)
} }
public func verify(_ profile: Profile) -> Set<AppFeature>? { public func requiredFeatures(_ profile: Profile) -> Set<AppFeature>? {
_verify(iapManager, profile) _requiredFeatures(iapManager, profile)
} }
public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { public func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {

View File

@ -31,7 +31,7 @@ public protocol ProfileProcessor {
func preview(from profile: Profile) -> ProfilePreview func preview(from profile: Profile) -> ProfilePreview
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder func requiredFeatures(_ profile: Profile) -> Set<AppFeature>?
func verify(_ profile: Profile) -> Set<AppFeature>? func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder
} }

View File

@ -36,8 +36,8 @@ extension View {
public func withMockEnvironment() -> some View { public func withMockEnvironment() -> some View {
task { task {
try? await AppContext.mock.profileManager.observeLocal() try? await AppContext.forPreviews.profileManager.observeLocal()
} }
.withEnvironment(from: .mock, theme: Theme()) .withEnvironment(from: .forPreviews, theme: Theme())
} }
} }

View File

@ -1,5 +1,5 @@
// //
// AppContext+Mock.swift // AppContext+Previews.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 6/22/24. // Created by Davide De Rosa on 6/22/24.
@ -23,25 +23,18 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import Combine
import CommonLibrary import CommonLibrary
import CommonUtils import CommonUtils
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
extension AppContext { extension AppContext {
public static let mock: AppContext = .mock(withRegistry: Registry()) public static let forPreviews: AppContext = {
public static func mock(withRegistry registry: Registry) -> AppContext {
let iapManager = IAPManager( let iapManager = IAPManager(
customUserLevel: .subscriber, customUserLevel: .subscriber,
inAppHelper: FakeAppProductHelper(), inAppHelper: FakeAppProductHelper(),
receiptReader: FakeAppReceiptReader(), receiptReader: FakeAppReceiptReader(),
betaChecker: TestFlightChecker(), betaChecker: TestFlightChecker(),
unrestrictedFeatures: [
.interactiveLogin,
.onDemand
],
productsAtBuild: { _ in productsAtBuild: { _ in
[] []
} }
@ -57,17 +50,23 @@ extension AppContext {
preview: { preview: {
$0.localizedPreview $0.localizedPreview
}, },
verify: { _, _ in requiredFeatures: { _, _ in
nil nil
}, },
willRebuild: { _, builder in willRebuild: { _, builder in
builder builder
}, },
willInstall: { _, profile in willInstall: { _, profile in
try profile.withProviderModules() profile
} }
) )
let profileManager: ProfileManager = .mock(withRegistry: registry, processor: processor) let profileManager = {
let profiles: [Profile] = (0..<20)
.reduce(into: []) { list, _ in
list.append(.newMockProfile())
}
return ProfileManager(profiles: profiles)
}()
let tunnelEnvironment = InMemoryEnvironment() let tunnelEnvironment = InMemoryEnvironment()
let tunnel = ExtendedTunnel( let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: tunnelEnvironment)), tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: tunnelEnvironment)),
@ -84,35 +83,35 @@ extension AppContext {
migrationManager: migrationManager, migrationManager: migrationManager,
profileManager: profileManager, profileManager: profileManager,
providerManager: providerManager, providerManager: providerManager,
registry: registry, registry: Registry(),
tunnel: tunnel, tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
) )
} }()
} }
// MARK: - Shortcuts // MARK: - Shortcuts
extension IAPManager { extension IAPManager {
public static var mock: IAPManager { public static var forPreviews: IAPManager {
AppContext.mock.iapManager AppContext.forPreviews.iapManager
} }
} }
extension ProfileManager { extension ProfileManager {
public static var mock: ProfileManager { public static var forPreviews: ProfileManager {
AppContext.mock.profileManager AppContext.forPreviews.profileManager
} }
} }
extension ExtendedTunnel { extension ExtendedTunnel {
public static var mock: ExtendedTunnel { public static var forPreviews: ExtendedTunnel {
AppContext.mock.tunnel AppContext.forPreviews.tunnel
} }
} }
extension ProviderManager { extension ProviderManager {
public static var mock: ProviderManager { public static var forPreviews: ProviderManager {
AppContext.mock.providerManager AppContext.forPreviews.providerManager
} }
} }

View File

@ -1,5 +1,5 @@
// //
// Profile+Mock.swift // Profile+Previews.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 11/4/24. // Created by Davide De Rosa on 11/4/24.
@ -27,7 +27,7 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension Profile { extension Profile {
public static let mock: Profile = { public static let forPreviews: Profile = {
var profile = Profile.Builder() var profile = Profile.Builder()
profile.name = "Mock profile" profile.name = "Mock profile"
do { do {
@ -63,7 +63,7 @@ extension Profile {
public static func newMockProfile(withName name: String? = nil) -> Profile { public static func newMockProfile(withName name: String? = nil) -> Profile {
do { do {
var copy = mock.builder(withNewId: true) var copy = forPreviews.builder(withNewId: true)
copy.name = name ?? String(copy.id.uuidString.prefix(8)) copy.name = name ?? String(copy.id.uuidString.prefix(8))
return try copy.tryBuild() return try copy.tryBuild()
} catch { } catch {

View File

@ -76,9 +76,9 @@ private extension ConnectionStatusText {
} }
#Preview("Connected") { #Preview("Connected") {
ConnectionStatusText(tunnel: .mock) ConnectionStatusText(tunnel: .forPreviews)
.task { .task {
try? await ExtendedTunnel.mock.connect(with: .mock) try? await ExtendedTunnel.forPreviews.connect(with: .forPreviews)
} }
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.withMockEnvironment() .withMockEnvironment()
@ -95,9 +95,9 @@ private extension ConnectionStatusText {
} catch { } catch {
fatalError() fatalError()
} }
return ConnectionStatusText(tunnel: .mock) return ConnectionStatusText(tunnel: .forPreviews)
.task { .task {
try? await ExtendedTunnel.mock.connect(with: profile) try? await ExtendedTunnel.forPreviews.connect(with: profile)
} }
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.withMockEnvironment() .withMockEnvironment()

View File

@ -30,14 +30,14 @@ import PassepartoutKit
final class MockProfileProcessor: ProfileProcessor { final class MockProfileProcessor: ProfileProcessor {
var isIncludedCount = 0 var isIncludedCount = 0
var willRebuildCount = 0
var verifyCount = 0
var isIncludedBlock: (Profile) -> Bool = { _ in true } var isIncludedBlock: (Profile) -> Bool = { _ in true }
var requiredFeaturesCount = 0
var requiredFeatures: Set<AppFeature>? var requiredFeatures: Set<AppFeature>?
var willRebuildCount = 0
func title(for profile: Profile) -> String { func title(for profile: Profile) -> String {
profile.name profile.name
} }
@ -51,13 +51,13 @@ final class MockProfileProcessor: ProfileProcessor {
ProfilePreview(profile) ProfilePreview(profile)
} }
func requiredFeatures(_ profile: Profile) -> Set<AppFeature>? {
requiredFeaturesCount += 1
return requiredFeatures
}
func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder { func willRebuild(_ builder: Profile.Builder) throws -> Profile.Builder {
willRebuildCount += 1 willRebuildCount += 1
return builder return builder
} }
func verify(_ profile: Profile) -> Set<AppFeature>? {
verifyCount += 1
return requiredFeatures
}
} }

View File

@ -100,8 +100,8 @@ extension ProfileManagerTests {
XCTAssertTrue(sut.isReady) XCTAssertTrue(sut.isReady)
XCTAssertEqual(processor.isIncludedCount, 1) XCTAssertEqual(processor.isIncludedCount, 1)
XCTAssertEqual(processor.requiredFeaturesCount, 1)
XCTAssertEqual(processor.willRebuildCount, 0) XCTAssertEqual(processor.willRebuildCount, 0)
XCTAssertEqual(processor.verifyCount, 1)
XCTAssertEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures) XCTAssertEqual(sut.requiredFeatures(forProfileWithId: profile.id), processor.requiredFeatures)
} }

View File

@ -38,6 +38,10 @@ import UITesting
extension AppContext { extension AppContext {
static let shared: AppContext = { static let shared: AppContext = {
let iapManager: IAPManager = .sharedForApp
let processor = InAppProcessor.shared(iapManager) {
$0.localizedPreview
}
// MARK: ProfileManager // MARK: ProfileManager
@ -65,7 +69,7 @@ extension AppContext {
backupRepository: Configuration.ProfileManager.backupProfileRepository, backupRepository: Configuration.ProfileManager.backupProfileRepository,
remoteRepositoryBlock: remoteRepositoryBlock, remoteRepositoryBlock: remoteRepositoryBlock,
mirrorsRemoteRepository: Configuration.ProfileManager.mirrorsRemoteRepository, mirrorsRemoteRepository: Configuration.ProfileManager.mirrorsRemoteRepository,
processor: IAPManager.sharedProcessor processor: processor
) )
}() }()
@ -74,7 +78,7 @@ extension AppContext {
let tunnel = ExtendedTunnel( let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: Configuration.ExtendedTunnel.strategy), tunnel: Tunnel(strategy: Configuration.ExtendedTunnel.strategy),
environment: .shared, environment: .shared,
processor: IAPManager.sharedProcessor, processor: processor,
interval: Constants.shared.tunnel.refreshInterval interval: Constants.shared.tunnel.refreshInterval
) )
@ -118,7 +122,7 @@ extension AppContext {
let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation) let migrationManager = MigrationManager(profileStrategy: profileStrategy, simulation: migrationSimulation)
return AppContext( return AppContext(
iapManager: .sharedForApp, iapManager: iapManager,
migrationManager: migrationManager, migrationManager: migrationManager,
profileManager: profileManager, profileManager: profileManager,
providerManager: providerManager, providerManager: providerManager,

View File

@ -37,44 +37,6 @@ extension IAPManager {
betaChecker: Configuration.IAPManager.betaChecker, betaChecker: Configuration.IAPManager.betaChecker,
productsAtBuild: Configuration.IAPManager.productsAtBuild productsAtBuild: Configuration.IAPManager.productsAtBuild
) )
static let sharedProcessor = InAppProcessor(
iapManager: sharedForApp,
title: {
Configuration.ProfileManager.sharedTitle($0)
},
isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1)
},
preview: {
$0.localizedPreview
},
verify: { iap, profile in
do {
try iap.verify(profile)
return nil
} catch AppError.ineligibleProfile(let requiredFeatures) {
return requiredFeatures
} catch {
return nil
}
},
willRebuild: { _, builder in
builder
},
willInstall: { iap, profile in
try iap.verify(profile)
// validate provider modules
do {
_ = try profile.withProviderModules()
return profile
} catch {
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
throw error
}
}
)
} }
// MARK: - Configuration // MARK: - Configuration
@ -90,7 +52,7 @@ private extension Configuration.IAPManager {
@MainActor @MainActor
static let simulatedInAppHelper: any AppProductHelper = { static let simulatedInAppHelper: any AppProductHelper = {
guard !AppCommandLine.contains(.fakeIAP) else { if AppCommandLine.contains(.fakeIAP) {
return FakeAppProductHelper() return FakeAppProductHelper()
} }
return inAppHelper return inAppHelper
@ -98,7 +60,7 @@ private extension Configuration.IAPManager {
@MainActor @MainActor
static var simulatedAppReceiptReader: AppReceiptReader { static var simulatedAppReceiptReader: AppReceiptReader {
guard !AppCommandLine.contains(.fakeIAP) else { if AppCommandLine.contains(.fakeIAP) {
guard let mockHelper = inAppHelper as? FakeAppProductHelper else { guard let mockHelper = inAppHelper as? FakeAppProductHelper else {
fatalError("When .isFakeIAP, productHelper is expected to be MockAppProductHelper") fatalError("When .isFakeIAP, productHelper is expected to be MockAppProductHelper")
} }

View File

@ -89,6 +89,50 @@ extension TunnelEnvironment where Self == AppGroupEnvironment {
} }
} }
// MARK: InAppProcessor
extension InAppProcessor {
@MainActor
static func shared(_ iapManager: IAPManager, preview: @escaping (Profile) -> ProfilePreview) -> InAppProcessor {
InAppProcessor(
iapManager: iapManager,
title: {
Configuration.ProfileManager.sharedTitle($0)
},
isIncluded: {
Configuration.ProfileManager.isIncluded($0, $1)
},
preview: preview,
requiredFeatures: { iap, profile in
do {
try iap.verify(profile)
return nil
} catch AppError.ineligibleProfile(let requiredFeatures) {
return requiredFeatures
} catch {
return nil
}
},
willRebuild: { _, builder in
builder
},
willInstall: { iap, profile in
try iap.verify(profile)
// validate provider modules
do {
_ = try profile.withProviderModules()
return profile
} catch {
pp_log(.app, .error, "Unable to inject provider modules: \(error)")
throw error
}
}
)
}
}
// MARK: - Configuration // MARK: - Configuration
enum Configuration { enum Configuration {

View File

@ -0,0 +1,73 @@
//
// AppContext+Testing.swift
// Passepartout
//
// Created by Davide De Rosa on 11/28/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import Foundation
import PassepartoutKit
import UILibrary
extension AppContext {
static func forUITesting(withRegistry registry: Registry) -> AppContext {
let iapManager = IAPManager(
customUserLevel: .subscriber,
inAppHelper: FakeAppProductHelper(),
receiptReader: FakeAppReceiptReader(),
betaChecker: TestFlightChecker(),
productsAtBuild: { _ in
[]
}
)
let processor = InAppProcessor.shared(iapManager) {
$0.localizedPreview
}
let profileManager: ProfileManager = .forTesting(
withRegistry: .shared,
processor: processor
)
let tunnelEnvironment = InMemoryEnvironment()
let tunnel = ExtendedTunnel(
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: tunnelEnvironment)),
environment: tunnelEnvironment,
processor: processor,
interval: Constants.shared.tunnel.refreshInterval
)
let providerManager = ProviderManager(
repository: InMemoryProviderRepository()
)
let migrationManager = MigrationManager()
return AppContext(
iapManager: iapManager,
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
registry: registry,
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
)
}
}

View File

@ -1,5 +1,5 @@
// //
// ProfileManager+Mock.swift // ProfileManager+Testing.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 11/28/24. // Created by Davide De Rosa on 11/28/24.
@ -28,7 +28,7 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension ProfileManager { extension ProfileManager {
public static func mock(withRegistry registry: Registry, processor: ProfileProcessor) -> ProfileManager { public static func forTesting(withRegistry registry: Registry, processor: ProfileProcessor) -> ProfileManager {
let repository = InMemoryProfileRepository() let repository = InMemoryProfileRepository()
let remoteRepository = InMemoryProfileRepository() let remoteRepository = InMemoryProfileRepository()
let manager = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in let manager = ProfileManager(repository: repository, remoteRepositoryBlock: { _ in