diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 3863d46..bd51b64 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 010F86BE2622F9C900B6C62A /* ShopListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F86BD2622F9C900B6C62A /* ShopListView.swift */; }; 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */; }; 0110A1612AC81978003EDCBA /* ResendConfirmationInstructionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110A1602AC81978003EDCBA /* ResendConfirmationInstructionsView.swift */; }; + 0114F3AC2E079BD100F4A1DD /* ShopListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */; }; 011586122B567363005E8E8F /* SignUpOrSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011586112B567363005E8E8F /* SignUpOrSignInView.swift */; }; 011DDC21287669EA00C6C21F /* SignUpRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DDC20287669EA00C6C21F /* SignUpRepository.swift */; }; 011DDC2328766C5E00C6C21F /* SignUpService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DDC2228766C5D00C6C21F /* SignUpService.swift */; }; @@ -172,6 +173,7 @@ 010F86BD2622F9C900B6C62A /* ShopListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListView.swift; sourceTree = ""; }; 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendConfirmation.swift; sourceTree = ""; }; 0110A1602AC81978003EDCBA /* ResendConfirmationInstructionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResendConfirmationInstructionsView.swift; sourceTree = ""; }; + 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListViewModel.swift; sourceTree = ""; }; 011586112B567363005E8E8F /* SignUpOrSignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpOrSignInView.swift; sourceTree = ""; }; 011DDC20287669EA00C6C21F /* SignUpRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpRepository.swift; sourceTree = ""; }; 011DDC2228766C5D00C6C21F /* SignUpService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpService.swift; sourceTree = ""; }; @@ -439,6 +441,7 @@ 013DE734284E99DF00528CC5 /* ShopCreateView.swift */, 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */, 010F86BD2622F9C900B6C62A /* ShopListView.swift */, + 0114F3AB2E079BD100F4A1DD /* ShopListViewModel.swift */, 017278952D7D99D100CE424F /* ItemTag Detail */, 017278992D7D99D100CE424F /* ItemTag List */, ); @@ -914,6 +917,7 @@ "", "", "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -969,6 +973,7 @@ 011DDC21287669EA00C6C21F /* SignUpRepository.swift in Sources */, 0172035B25A9642E008FD63B /* Service.swift in Sources */, 01E0A5B625BD0FCD00298D35 /* LoadingView.swift in Sources */, + 0114F3AC2E079BD100F4A1DD /* ShopListViewModel.swift in Sources */, 0172051A25AAF6C0008FD63B /* SessionsService.swift in Sources */, 017204D125AA8479008FD63B /* DataState.swift in Sources */, 012643372B3554AD00D4E9BD /* AcceptTermsView.swift in Sources */, diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index e851df4..3b9f18b 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -148,10 +148,14 @@ private extension MainView { } func shopListView() -> ShopListView { - .init( + let viewModel = ShopListViewModel( + sessionController: sessionController, shopRepository: dataManager.shopRepository, - itemTagRepository: dataManager.itemTagRepository + itemTagRepository: dataManager.itemTagRepository, + tabViewModel: tabViewModel, + mainTab: .shops ) + return ShopListView(viewModel: viewModel) } func scanView() -> ScanView { diff --git a/NativeAppTemplate/UI/Shop List/ShopListView.swift b/NativeAppTemplate/UI/Shop List/ShopListView.swift index cde5dbf..7ee6854 100644 --- a/NativeAppTemplate/UI/Shop List/ShopListView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopListView.swift @@ -44,19 +44,10 @@ struct TapShopBelowTip: Tip { } struct ShopListView: View { - @Environment(\.mainTab) private var mainTab - @Environment(TabViewModel.self) private var tabViewModel - @Environment(\.sessionController) private var sessionController - private var shopRepository: ShopRepositoryProtocol - private var itemTagRepository: ItemTagRepositoryProtocol - @State private var isShowingCreateSheet = false + @State private var viewModel: ShopListViewModel - init( - shopRepository: ShopRepositoryProtocol, - itemTagRepository: ItemTagRepositoryProtocol - ) { - self.shopRepository = shopRepository - self.itemTagRepository = itemTagRepository + init(viewModel: ShopListViewModel) { + self._viewModel = State(wrappedValue: viewModel) } } @@ -64,21 +55,21 @@ extension ShopListView { var body: some View { contentView .task { - reload() + viewModel.reload() } .onAppear { - tabViewModel.showingDetailView[mainTab] = false + viewModel.setTabViewModelShowingDetailViewToFalse() } - .onChange(of: shopRepository.state) { - if shopRepository.state == .initial { - reload() + .onChange(of: viewModel.state) { + if viewModel.state == .initial { + viewModel.reload() } } // Avoid showing deleted shop. - .onChange(of: sessionController.shouldPopToRootView) { + .onChange(of: viewModel.shouldPopToRootView) { Task { try await Task.sleep(nanoseconds: 2_000_000_000) - reload() + viewModel.reload() } } } @@ -88,7 +79,7 @@ extension ShopListView { private extension ShopListView { var contentView: some View { @ViewBuilder var contentView: some View { - switch shopRepository.state { + switch viewModel.state { case .initial, .loading: LoadingView() case .hasData: @@ -101,12 +92,8 @@ private extension ShopListView { return contentView } - func reload() { - shopRepository.reload() - } - var cardsView: some View { - ForEach(shopRepository.shops) { shop in + ForEach(viewModel.shops) { shop in NavigationLink(value: shop) { ShopListCardView(shop: shop) } @@ -115,11 +102,9 @@ private extension ShopListView { } var shopListView: some View { - let leftInShopSlots = shopRepository.limitCount - shopRepository.createdShopsCount - - return VStack { - if shopRepository.isEmpty { - noResultsView(leftInShopSlots: leftInShopSlots) + VStack { + if viewModel.isEmpty { + noResultsView(leftInShopSlots: viewModel.leftInShopSlots) } else { List { Section { @@ -130,11 +115,11 @@ private extension ShopListView { .tint(.alarm) EmptyView() - .id(ScrollToTopID(mainTab: mainTab, detail: false)) + .id(viewModel.scrollToTopID()) } footer: { VStack(spacing: 0) { HStack(alignment: .firstTextBaseline) { - Text(String(leftInShopSlots)) + Text(String(viewModel.leftInShopSlots)) .font(.uiLabelBold) Text(verbatim: "left in shop slots.") .font(.uiFootnote) @@ -144,41 +129,41 @@ private extension ShopListView { } .navigationDestination(for: Shop.self) { shop in ShopDetailView( - shopRepository: shopRepository, - itemTagRepository: itemTagRepository, + shopRepository: viewModel.shopRepository, + itemTagRepository: viewModel.itemTagRepository, shopId: shop.id ) } .accessibility(identifier: "shopListView") .refreshable { - reload() + viewModel.reload() } } } .navigationTitle(String.shops) .navigationBarTitleDisplayMode(.inline) .toolbar { - if leftInShopSlots > 0 { + if viewModel.leftInShopSlots > 0 { ToolbarItem(placement: .navigationBarTrailing) { Button { - isShowingCreateSheet.toggle() + viewModel.showCreateView() } label: { Image(systemName: "plus") } } } } - .sheet(isPresented: $isShowingCreateSheet, + .sheet(isPresented: $viewModel.isShowingCreateSheet, onDismiss: { - reload() + viewModel.reload() }, content: { - ShopCreateView(shopRepository: shopRepository) + ShopCreateView(shopRepository: viewModel.shopRepository) } ) } var reloadView: some View { - ErrorView(buttonAction: reload) + ErrorView(buttonAction: viewModel.reload) } func noResultsView(leftInShopSlots: Int) -> some View { @@ -195,7 +180,7 @@ private extension ShopListView { .padding() MainButtonView(title: String.addShop, type: .primary(withArrow: false)) { - isShowingCreateSheet.toggle() + viewModel.showCreateView() } .padding() diff --git a/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift b/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift new file mode 100644 index 0000000..6ad7a64 --- /dev/null +++ b/NativeAppTemplate/UI/Shop List/ShopListViewModel.swift @@ -0,0 +1,59 @@ +// +// ShopListViewModel.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import SwiftUI +import Observation + +@Observable +@MainActor +final class ShopListViewModel { + var isShowingCreateSheet = false + + var state: DataState { shopRepository.state } + var shops: [Shop] { shopRepository.shops } + var limitCount: Int { shopRepository.limitCount } + var createdShopsCount: Int { shopRepository.createdShopsCount } + var isEmpty: Bool { shopRepository.isEmpty } + var leftInShopSlots: Int { limitCount - createdShopsCount } + var shouldPopToRootView: Bool { sessionController.shouldPopToRootView } + + let shopRepository: ShopRepositoryProtocol + let itemTagRepository: ItemTagRepositoryProtocol + private let sessionController: SessionControllerProtocol + private let tabViewModel: TabViewModel + private let mainTab: MainTab + + init( + sessionController: SessionControllerProtocol, + shopRepository: ShopRepositoryProtocol, + itemTagRepository: ItemTagRepositoryProtocol, + tabViewModel: TabViewModel, + mainTab: MainTab + ) { + self.sessionController = sessionController + self.shopRepository = shopRepository + self.itemTagRepository = itemTagRepository + self.tabViewModel = tabViewModel + self.mainTab = mainTab + } + + func reload() { + shopRepository.reload() + } + + func showCreateView() { + isShowingCreateSheet.toggle() + } + + func setTabViewModelShowingDetailViewToFalse() { + tabViewModel.showingDetailView[mainTab] = false + } + + func scrollToTopID() -> ScrollToTopID { + ScrollToTopID(mainTab: mainTab, detail: false) + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepository.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepository.swift new file mode 100644 index 0000000..a217f60 --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepository.swift @@ -0,0 +1,45 @@ +// +// DemoOnboardingRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +@testable import NativeAppTemplate +import Foundation +import OrderedCollections + +@MainActor +final class DemoOnboardingRepository: OnboardingRepositoryProtocol { + var onboardings: [Onboarding] = [] + var onboardingsDictionary: OrderedDictionary { + var dict = OrderedDictionary() + for onboarding in onboardings { + dict[onboarding.id] = onboarding.isPortraitImage + } + return dict + } + + func reload() { + // Demo data with predefined onboarding items + let demoOnboardingData: OrderedDictionary = [ + 1: false, // Landscape image + 2: false, // Landscape image + 3: false, // Landscape image + 4: true, // Portrait image + 5: false, // Landscape image + 6: false, // Landscape image + 7: true, // Portrait image + 8: true, // Portrait image + 9: false, // Landscape image + 10: false, // Landscape image + 11: true, // Portrait image + 12: false, // Landscape image + 13: false // Landscape image + ] + + onboardings = demoOnboardingData.map { key, value in + Onboarding(id: key, isPortraitImage: value) + } + } +} diff --git a/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepositoryTest.swift b/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepositoryTest.swift new file mode 100644 index 0000000..a9c0d69 --- /dev/null +++ b/NativeAppTemplateTests/Demo/Data/Repositories/DemoOnboardingRepositoryTest.swift @@ -0,0 +1,84 @@ +// +// DemoOnboardingRepositoryTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +@testable import NativeAppTemplate + +@Suite +struct DemoOnboardingRepositoryTest { + @MainActor + struct Tests { + let repository = DemoOnboardingRepository() + + @Test + func reload() { + repository.reload() + + #expect(repository.onboardings.count == 13) + #expect(!repository.onboardings.isEmpty) + } + + @Test + func onboardingsDictionary() { + repository.reload() + + let dictionary = repository.onboardingsDictionary + #expect(dictionary.count == 13) + // Test specific values from the demo data + #expect(dictionary[1] == false) // Landscape + #expect(dictionary[4] == true) // Portrait + #expect(dictionary[7] == true) // Portrait + #expect(dictionary[8] == true) // Portrait + #expect(dictionary[11] == true) // Portrait + #expect(dictionary[13] == false) // Landscape + } + + @Test + func onboardingProperties() { + repository.reload() + + let firstOnboarding = repository.onboardings.first { $0.id == 1 } + #expect(firstOnboarding != nil) + #expect(firstOnboarding?.isPortraitImage == false) + + let portraitOnboarding = repository.onboardings.first { $0.id == 4 } + #expect(portraitOnboarding != nil) + #expect(portraitOnboarding?.isPortraitImage == true) + } + + @Test + func onboardingIds() { + repository.reload() + + let ids = repository.onboardings.map { $0.id }.sorted() + let expectedIds = Array(1...13) + #expect(ids == expectedIds) + } + + @Test + func portraitImageCounts() { + repository.reload() + + let portraitCount = repository.onboardings.filter { $0.isPortraitImage }.count + let landscapeCount = repository.onboardings.filter { !$0.isPortraitImage }.count + + #expect(portraitCount == 4) // IDs: 4, 7, 8, 11 + #expect(landscapeCount == 9) // All others + #expect(portraitCount + landscapeCount == 13) + } + + @Test + func dictionaryConsistency() { + repository.reload() + + // Verify that the dictionary computed property matches the onboardings array + for onboarding in repository.onboardings { + #expect(repository.onboardingsDictionary[onboarding.id] == onboarding.isPortraitImage) + } + } + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestAccountPasswordRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestAccountPasswordRepository.swift new file mode 100644 index 0000000..03238ba --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestAccountPasswordRepository.swift @@ -0,0 +1,29 @@ +// +// TestAccountPasswordRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor +final class TestAccountPasswordRepository: AccountPasswordRepositoryProtocol { + // A test-only + var error: NativeAppTemplateAPIError? + var updateCalled = false + var lastUpdatePassword: UpdatePassword? + + required init(accountPasswordService: AccountPasswordService) { + } + + func update(updatePassword: UpdatePassword) async throws { + updateCalled = true + lastUpdatePassword = updatePassword + + guard error == nil else { + throw error! + } + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift new file mode 100644 index 0000000..85f05f7 --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestItemTagRepository.swift @@ -0,0 +1,130 @@ +// +// TestItemTagRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor +final class TestItemTagRepository: ItemTagRepositoryProtocol { + var itemTags: [ItemTag] = [] + var state: DataState = .initial + var isEmpty: Bool { itemTags.isEmpty } + + // A test-only + var error: NativeAppTemplateAPIError? + + required init(itemTagsService: ItemTagsService) { + } + + func findBy(id: String) -> ItemTag { + guard let itemTag = itemTags.first(where: { $0.id == id }) else { + fatalError("Test setup error: ItemTag with id '\(id)' not found. Available IDs: \(itemTags.map { $0.id })") + } + return itemTag + } + + func reload(shopId: String) { + guard error == nil else { + state = .failed + return + } + + state = .loading + state = .hasData + } + + func fetchAll(shopId: String) async throws -> [ItemTag] { + guard error == nil else { + state = .failed + throw error! + } + + return itemTags + } + + func fetchDetail(id: String) async throws -> ItemTag { + guard error == nil else { + state = .failed + throw error! + } + + guard let itemTag = itemTags.first(where: { $0.id == id }) else { + throw NativeAppTemplateAPIError.requestFailed(nil, 404, "ItemTag with id '\(id)' not found") + } + return itemTag + } + + func create(shopId: String, itemTag: ItemTag) async throws -> ItemTag { + guard error == nil else { + state = .failed + throw error! + } + + itemTags.append(itemTag) + return itemTag + } + + func update(id: String, itemTag: ItemTag) async throws -> ItemTag { + guard error == nil else { + state = .failed + throw error! + } + + if let index = itemTags.firstIndex(where: { $0.id == id }) { + itemTags[index] = itemTag + } + + return itemTag + } + + func destroy(id: String) async throws { + guard error == nil else { + state = .failed + throw error! + } + + itemTags.removeAll { $0.id == id } + } + + func complete(id: String) async throws -> ItemTag { + guard error == nil else { + state = .failed + throw error! + } + + var itemTag = findBy(id: id) + let wasAlreadyCompleted = itemTag.alreadyCompleted + itemTag.state = .completed + itemTag.completedAt = Date() + _ = try await update(id: id, itemTag: itemTag) + + // Preserve the alreadyCompleted flag for testing + itemTag.alreadyCompleted = wasAlreadyCompleted + + return itemTag + } + + func reset(id: String) async throws -> ItemTag { + guard error == nil else { + state = .failed + throw error! + } + + var itemTag = findBy(id: id) + itemTag.state = .idled + itemTag.scanState = .unscanned + itemTag.completedAt = nil + _ = try await update(id: id, itemTag: itemTag) + + return itemTag + } + + // A test-only + func setItemTags(itemTags: [ItemTag]) { + self.itemTags = itemTags + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift new file mode 100644 index 0000000..55e6ab6 --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestLoginRepository.swift @@ -0,0 +1,59 @@ +// +// TestLoginRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor +public final class TestLoginRepository: LoginRepositoryProtocol { + + public var currentShopkeeper: Shopkeeper? + + public var loginCalled = false + public var logoutCalled = false + public var updateShopkeeperCalled = false + + public init() {} + + public func login(email: String, password: String) async throws -> Shopkeeper { + loginCalled = true + + guard let shopkeeper = Shopkeeper( + id: UUID().uuidString, + accountId: "mockAccountId", + personalAccountId: "mockPersonalAccountId", + accountOwnerId: "mockAccountOwnerId", + accountName: "Mock Account", + email: email, + name: "Mock Name", + timeZone: "UTC", + uid: "mockUID", + token: "mockToken", + client: "mockClient", + expiry: "9999999999" + ) else { + throw NSError( + domain: "TestLoginRepository", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create mock Shopkeeper"] + ) + } + + currentShopkeeper = shopkeeper + return shopkeeper + } + + public func logout(networkClient: NativeAppTemplateAPI) async throws { + logoutCalled = true + currentShopkeeper = nil + } + + public func updateShopkeeper(shopkeeper: Shopkeeper?) throws { + updateShopkeeperCalled = true + currentShopkeeper = shopkeeper + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestOnboardingRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestOnboardingRepository.swift new file mode 100644 index 0000000..d152089 --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestOnboardingRepository.swift @@ -0,0 +1,34 @@ +// +// TestOnboardingRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +import OrderedCollections +@testable import NativeAppTemplate + +@MainActor +final class TestOnboardingRepository: OnboardingRepositoryProtocol { + var onboardings: [Onboarding] = [] + var onboardingsDictionary: OrderedDictionary { + var dict = OrderedDictionary() + for onboarding in onboardings { + dict[onboarding.id] = onboarding.isPortraitImage + } + return dict + } + + // A test-only + var reloadCalled = false + + func reload() { + reloadCalled = true + } + + // A test-only + func setOnboardings(onboardings: [Onboarding]) { + self.onboardings = onboardings + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift new file mode 100644 index 0000000..af32225 --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestSessionController.swift @@ -0,0 +1,91 @@ +// +// TestSessionController.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor @Observable public class TestSessionController: SessionControllerProtocol { + // MARK: - Properties + public var sessionState: SessionState = .unknown + public var userState: UserState = .loggedIn + public var permissionState: PermissionState = .notLoaded + public var didFetchPermissions: Bool = false + + public var shouldPopToRootView: Bool = false + public var didBackgroundTagReading: Bool = false + + public var completeScanResult: CompleteScanResult = .init() + public var showTagInfoScanResult: ShowTagInfoScanResult = .init() + + public var shouldUpdateApp: Bool = false + public var shouldUpdatePrivacy: Bool = false + public var shouldUpdateTerms: Bool = false + public var shouldThrowPrivacyError: Bool = false + public var shouldThrowTermsError: Bool = false + public var maximumQueueNumberLength: Int = 4 + public var shopLimitCount: Int = 1 + + public var shopkeeper: Shopkeeper? + public private(set) var client = NativeAppTemplateAPI(authToken: "", client: "", expiry: "", uid: "", accountId: "") + + public var isLoggedIn: Bool { + userState == .loggedIn + } + + public var hasPermissions: Bool { + switch permissionState { + case .loaded: + return true + default: + return false + } + } + + // MARK: - Initializer + public nonisolated init() {} + + // MARK: - Methods + public func login(email: String, password: String) async throws { + userState = .loggedIn + sessionState = .online + } + + public func logout() async throws { + userState = .notLoggedIn + sessionState = .offline + shopkeeper = nil + } + + public func fetchPermissionsIfNeeded() { + didFetchPermissions = true + permissionState = .loaded + } + + public func fetchPermissions() { + permissionState = .loading + // Mocking immediate load + permissionState = .loaded + } + + public func updateShopkeeper(shopkeeper: Shopkeeper?) throws { + self.shopkeeper = shopkeeper + } + + public func updateConfirmedPrivacyVersion() async throws { + if shouldThrowPrivacyError { + throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Privacy update failed"]) + } + shouldUpdatePrivacy = false + } + + public func updateConfirmedTermsVersion() async throws { + if shouldThrowTermsError { + throw NSError(domain: "TestError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Terms update failed"]) + } + shouldUpdateTerms = false + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestShopRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestShopRepository.swift new file mode 100644 index 0000000..b978ace --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestShopRepository.swift @@ -0,0 +1,95 @@ +// +// TestShopRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor +final class TestShopRepository: ShopRepositoryProtocol { + var shops: [Shop] = [] + var state: DataState = .initial + var limitCount: Int = 0 + var createdShopsCount: Int = 0 + var isEmpty: Bool { shops.isEmpty } + + // A test-only + var error: NativeAppTemplateAPIError? + + required init(shopsService: ShopsService) { + createdShopsCount = shops.count + } + + func findBy(id: String) -> Shop { + guard let shop = shops.first(where: { $0.id == id }) else { + fatalError("Test setup error: Shop with id '\(id)' not found. Available IDs: \(shops.map { $0.id })") + } + return shop + } + + func reload() { + guard error == nil else { + state = .failed + return + } + + state = .loading + createdShopsCount = shops.count + state = .hasData + } + + func fetchDetail(id: String) async throws -> Shop { + guard error == nil else { + throw error! + } + + guard let shop = shops.first(where: { $0.id == id }) else { + throw NativeAppTemplateAPIError.requestFailed(nil, 404, "Shop with id '\(id)' not found") + } + return shop + } + + func create(shop: Shop) async throws -> Shop { + guard error == nil else { + throw error! + } + + shops.append(shop) + createdShopsCount += 1 + return shop + } + + func update(id: String, shop: Shop) async throws -> Shop { + guard error == nil else { + throw error! + } + + if let index = shops.firstIndex(where: { $0.id == id }) { + shops[index] = shop + } + return shop + } + + func destroy(id: String) async throws { + guard error == nil else { + throw error! + } + + shops.removeAll { $0.id == id } + } + + func reset(id: String) async throws { + guard error == nil else { + throw error! + } + } + + // A test-only + func setShops(shops: [Shop]) { + self.shops = shops + createdShopsCount = shops.count + } +} diff --git a/NativeAppTemplateTests/Testing/Repositories/TestSignUpRepository.swift b/NativeAppTemplateTests/Testing/Repositories/TestSignUpRepository.swift new file mode 100644 index 0000000..f372c5f --- /dev/null +++ b/NativeAppTemplateTests/Testing/Repositories/TestSignUpRepository.swift @@ -0,0 +1,100 @@ +// +// TestSignUpRepository.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Foundation +@testable import NativeAppTemplate + +@MainActor +final class TestSignUpRepository: SignUpRepositoryProtocol { + // A test-only + var error: NativeAppTemplateAPIError? + var signUpCalled = false + var updateCalled = false + var destroyCalled = false + var sendResetPasswordCalled = false + var sendConfirmationCalled = false + var lastSignUp: SignUp? + var lastUpdateId: String? + var lastSendResetPassword: SendResetPassword? + var lastSendConfirmation: SendConfirmation? + var shopkeeperToReturn: Shopkeeper? + + func signUp(signUp: SignUp) async throws -> Shopkeeper { + signUpCalled = true + lastSignUp = signUp + + guard error == nil else { + throw error! + } + + return shopkeeperToReturn ?? Shopkeeper(dictionary: [ + "id": "1", + "account_id": "account_1", + "personal_account_id": "personal_1", + "account_owner_id": "owner_1", + "account_name": "Test Account", + "email": signUp.email, + "name": signUp.name, + "time_zone": signUp.timeZone, + "uid": signUp.email, + "token": "test_token", + "client": "test_client", + "expiry": "123456789" + ])! + } + + func update(id: String, signUp: SignUp, networkClient: NativeAppTemplateAPI) async throws -> Shopkeeper { + updateCalled = true + lastUpdateId = id + lastSignUp = signUp + + guard error == nil else { + throw error! + } + + return shopkeeperToReturn ?? Shopkeeper(dictionary: [ + "id": id, + "account_id": "account_1", + "personal_account_id": "personal_1", + "account_owner_id": "owner_1", + "account_name": "Test Account", + "email": signUp.email, + "name": signUp.name, + "time_zone": signUp.timeZone, + "uid": signUp.email, + "token": "test_token", + "client": "test_client", + "expiry": "123456789" + ])! + } + + func destroy(networkClient: NativeAppTemplateAPI) async throws { + destroyCalled = true + + guard error == nil else { + throw error! + } + } + + func sendResetPasswordInstruction(sendResetPassword: SendResetPassword) async throws { + sendResetPasswordCalled = true + lastSendResetPassword = sendResetPassword + + guard error == nil else { + throw error! + } + } + + func sendConfirmationInstruction(sendConfirmation: SendConfirmation) async throws { + sendConfirmationCalled = true + lastSendConfirmation = sendConfirmation + + guard error == nil else { + throw error! + } + } +} diff --git a/NativeAppTemplateTests/Testing/TestNFCManager.swift b/NativeAppTemplateTests/Testing/TestNFCManager.swift new file mode 100644 index 0000000..df1cafc --- /dev/null +++ b/NativeAppTemplateTests/Testing/TestNFCManager.swift @@ -0,0 +1,69 @@ +// +// TestNFCManager.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/20. +// + +import Foundation +import CoreNFC +@testable import NativeAppTemplate + +final class TestNFCManager: NFCManagerProtocol, @unchecked Sendable { + var scanResult: Result? + var isScanResultChanged = false + var isScanResultChangedForTesting = false + + // Test control properties + var shouldSimulateSuccess = true + var simulatedItemTagData: ItemTagData? + var simulatedError: Error? + var readingStarted = false + var testingStarted = false + var writingStarted = false + + func startReading() async { + readingStarted = true + await simulateScanResult() + } + + func startReadingForTesting() async { + testingStarted = true + await simulateScanResultForTesting() + } + + func startWriting(ndefMessage: NFCNDEFMessage, isLock: Bool) async { + writingStarted = true + } + + // Test helper methods + @MainActor func simulateScanResult() { + if shouldSimulateSuccess, let itemTagData = simulatedItemTagData { + scanResult = .success(itemTagData) + } else if let error = simulatedError { + scanResult = .failure(error) + } + isScanResultChanged = true + } + + @MainActor func simulateScanResultForTesting() { + if shouldSimulateSuccess, let itemTagData = simulatedItemTagData { + scanResult = .success(itemTagData) + } else if let error = simulatedError { + scanResult = .failure(error) + } + isScanResultChangedForTesting = true + } + + @MainActor func reset() { + scanResult = nil + isScanResultChanged = false + isScanResultChangedForTesting = false + readingStarted = false + testingStarted = false + writingStarted = false + shouldSimulateSuccess = true + simulatedItemTagData = nil + simulatedError = nil + } +} diff --git a/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift new file mode 100644 index 0000000..93d1a5d --- /dev/null +++ b/NativeAppTemplateTests/UI/Shop List/ShopListViewModelTest.swift @@ -0,0 +1,211 @@ +// +// ShopListViewModelTest.swift +// NativeAppTemplate +// +// Created by Claude on 2025/06/22. +// + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +struct ShopListViewModelTest { + var shops: [Shop] { + [ + mockShop(id: "1", name: "Shop 1"), + mockShop(id: "2", name: "Shop 2"), + mockShop(id: "3", name: "Shop 3"), + mockShop(id: "4", name: "Shop 4"), + mockShop(id: "5", name: "Shop 5") + ] + } + + let sessionController = TestSessionController() + let shopRepository = TestShopRepository( + shopsService: ShopsService() + ) + let itemTagRepository = TestItemTagRepository( + itemTagsService: ItemTagsService() + ) + let tabViewModel = TabViewModel() + let mainTab = MainTab.shops + + @Test + func leftInShopSlots() { + shopRepository.limitCount = 5 + shopRepository.createdShopsCount = 2 + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + #expect(viewModel.leftInShopSlots == 3) + } + + @Test("Should pop to root view", arguments: [false, true]) + func shouldPopToRootView(shouldPopToRootView: Bool) { + sessionController.shouldPopToRootView = shouldPopToRootView + + shopRepository.setShops(shops: shops) + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.reload() + + #expect(viewModel.shouldPopToRootView == shouldPopToRootView) + } + + @Test + func reload() { + shopRepository.setShops(shops: shops) + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.reload() + + #expect(viewModel.shops.count == 5) + } + + @Test + func reloadFailed() { + shopRepository.setShops(shops: shops) + shopRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 422, nil) + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.reload() + + #expect(viewModel.state == .failed) + } + + @Test + func showCreateView() { + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + #expect(viewModel.isShowingCreateSheet == false) + + viewModel.showCreateView() + + #expect(viewModel.isShowingCreateSheet == true) + + viewModel.showCreateView() + + #expect(viewModel.isShowingCreateSheet == false) + } + + @Test + func setTabViewModelShowingDetailViewToFalse() { + tabViewModel.showingDetailView[mainTab] = true + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.setTabViewModelShowingDetailViewToFalse() + + #expect(tabViewModel.showingDetailView[mainTab] == false) + } + + @Test + func scrollToTopID() { + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + let scrollToTopID = viewModel.scrollToTopID() + + #expect(scrollToTopID == ScrollToTopID(mainTab: mainTab, detail: false)) + } + + @Test + func repositoryProperties() { + shopRepository.setShops(shops: shops) + shopRepository.limitCount = 10 + shopRepository.createdShopsCount = 5 + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.reload() + + #expect(viewModel.shops.count == 5) + #expect(viewModel.limitCount == 10) + #expect(viewModel.createdShopsCount == 5) + #expect(viewModel.isEmpty == false) + #expect(viewModel.leftInShopSlots == 5) + } + + @Test + func emptyState() { + shopRepository.setShops(shops: []) + + let viewModel = ShopListViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + itemTagRepository: itemTagRepository, + tabViewModel: tabViewModel, + mainTab: mainTab + ) + + viewModel.reload() + + #expect(viewModel.shops.isEmpty == true) + #expect(viewModel.isEmpty == true) + } + + private func mockShop(id: String = UUID().uuidString, name: String = "Mock Shop") -> Shop { + Shop( + id: id, + name: name, + description: "This is a mock shop for testing", + timeZone: "Tokyo", + itemTagsCount: 10, + scannedItemTagsCount: 5, + completedItemTagsCount: 3, + displayShopServerPath: "https://api.nativeapptemplate.com/display/shops/\(id)?type=server" + ) + } +}