From 64e9a5c3e049a6e9ee3575b65de9ddd21462ab6e Mon Sep 17 00:00:00 2001 From: dadachi Date: Sun, 22 Jun 2025 20:19:59 +0900 Subject: [PATCH] add ScanViewModel --- NativeAppTemplate.xcodeproj/project.pbxproj | 5 + NativeAppTemplate/NFCManager.swift | 34 +- NativeAppTemplate/UI/App Root/MainView.swift | 7 +- NativeAppTemplate/UI/Scan/ScanView.swift | 194 +------ NativeAppTemplate/UI/Scan/ScanViewModel.swift | 196 +++++++ .../UI/Scan/ScanViewModelTest.swift | 483 ++++++++++++++++++ 6 files changed, 735 insertions(+), 184 deletions(-) create mode 100644 NativeAppTemplate/UI/Scan/ScanViewModel.swift create mode 100644 NativeAppTemplateTests/UI/Scan/ScanViewModelTest.swift diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index e1dbf8e..2fcbe6a 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ 01D85B462E07F15400A95798 /* PasswordEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B452E07F15400A95798 /* PasswordEditViewModel.swift */; }; 01D85B482E07F16100A95798 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B472E07F16100A95798 /* SettingsViewModel.swift */; }; 01D85B4A2E07F16900A95798 /* ShopkeeperEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85B492E07F16900A95798 /* ShopkeeperEditViewModel.swift */; }; + 01D85BA72E081C6D00A95798 /* ScanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D85BA62E081C6D00A95798 /* ScanViewModel.swift */; }; 01D8AE8B2AB453C1009AFFBA /* ShopBasicSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */; }; 01DCE23F298FA3B300BA311D /* ShopListCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */; }; 01E0A59C25BD088600298D35 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E0A59125BD087E00298D35 /* SettingsView.swift */; }; @@ -312,6 +313,7 @@ 01D85B452E07F15400A95798 /* PasswordEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEditViewModel.swift; sourceTree = ""; }; 01D85B472E07F16100A95798 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 01D85B492E07F16900A95798 /* ShopkeeperEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopkeeperEditViewModel.swift; sourceTree = ""; }; + 01D85BA62E081C6D00A95798 /* ScanViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanViewModel.swift; sourceTree = ""; }; 01D8AE8A2AB453C1009AFFBA /* ShopBasicSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopBasicSettingsView.swift; sourceTree = ""; }; 01DCE23E298FA3B300BA311D /* ShopListCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListCardView.swift; sourceTree = ""; }; 01E0A59125BD087E00298D35 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -437,6 +439,7 @@ children = ( 0135E7152D7E33F9004AD8FA /* CompleteScanResultView.swift */, 0135E7162D7E33F9004AD8FA /* ScanView.swift */, + 01D85BA62E081C6D00A95798 /* ScanViewModel.swift */, 0135E7172D7E33F9004AD8FA /* ShowTagInfoScanResultView.swift */, ); path = Scan; @@ -958,6 +961,7 @@ "", "", "", + "", ); }; /* End PBXShellScriptBuildPhase section */ @@ -1033,6 +1037,7 @@ 0182D39A25B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift in Sources */, 01ED197B2A037B9E00CD4735 /* AppTabView.swift in Sources */, 0110A1612AC81978003EDCBA /* ResendConfirmationInstructionsView.swift in Sources */, + 01D85BA72E081C6D00A95798 /* ScanViewModel.swift in Sources */, 01E0A62225BD4A7800298D35 /* Shopkeeper+Backdoor.swift in Sources */, 0106413C29A9EDFF00B46FED /* AccountPasswordRequest.swift in Sources */, 0172035625A9642E008FD63B /* ShopAdapter.swift in Sources */, diff --git a/NativeAppTemplate/NFCManager.swift b/NativeAppTemplate/NFCManager.swift index dffaba1..53f183c 100644 --- a/NativeAppTemplate/NFCManager.swift +++ b/NativeAppTemplate/NFCManager.swift @@ -8,11 +8,11 @@ import Foundation import CoreNFC -protocol NFCManagerProtocol { +protocol NFCManagerProtocol: Sendable { @MainActor var scanResult: Result? { get } @MainActor var isScanResultChanged: Bool { get } @MainActor var isScanResultChangedForTesting: Bool { get } - + func startReading() async func startReadingForTesting() async @@ -21,7 +21,7 @@ protocol NFCManagerProtocol { final class NFCManager: NSObject, ObservableObject, @unchecked Sendable { @MainActor static let shared = NFCManager() - + @MainActor @Published var scanResult: Result? @MainActor @Published var isScanResultChanged = false @MainActor @Published var isScanResultChangedForTesting = false @@ -61,7 +61,7 @@ final class NFCManager: NSObject, ObservableObject, @unchecked Sendable { case readForTesting case write } - + var nfcSession: NFCNDEFReaderSession? var nfcOperation = NFCOperation.read private var userNdefMessage: NFCNDEFMessage? @@ -85,7 +85,7 @@ extension NFCManager: NFCManagerProtocol { nfcOperation = .readForTesting startSesstion() } - + func startWriting(ndefMessage: NFCNDEFMessage, isLock: Bool) async { nfcOperation = .write userNdefMessage = ndefMessage @@ -102,22 +102,22 @@ extension NFCManager: NFCManagerProtocol { extension NFCManager: NFCNDEFReaderSessionDelegate { func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { } - + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { guard let tag = tags.first else { return } - + session.connect(to: tag) { error in if let error = error { session.invalidate(errorMessage: "Connection error: \(error.localizedDescription)") return } - + tag.queryNDEFStatus { status, capacity, error in if let error = error { session.invalidate(errorMessage: "Checking NDEF status error: \(error.localizedDescription)") return } - + switch status { case .notSupported: session.invalidate(errorMessage: String.tagIsNotNdefFormatted) @@ -145,14 +145,14 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { self.write(session: session, tag: tag) } - + @unknown default: session.invalidate(errorMessage: String.unknownNdefStatus) } } } } - + private func read( session: NFCNDEFReaderSession, tag: NFCNDEFTag, @@ -169,7 +169,7 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { } return } - + guard let message else { session.invalidate(errorMessage: String.noRecrodsFound) self?.internalScanResult = .failure(ScanResultError.failed(String.tagNotValid)) @@ -194,10 +194,10 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { session.invalidate() } } - + private func write(session: NFCNDEFReaderSession, tag: NFCNDEFTag) { guard let userNdefMessage = self.userNdefMessage else { return } - + write( session: session, tag: tag, @@ -208,7 +208,7 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { print(">>> Write: \(userNdefMessage)") } } - + private func write( session: NFCNDEFReaderSession, tag: NFCNDEFTag, @@ -243,7 +243,7 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { private func setResultExtractedFrom(message: NFCNDEFMessage, isReadOnly: Bool, test: Bool) { let itemTagInfo = Utility.extractItemTagInfoFrom(message: message, test: test) - + if itemTagInfo.success { let itemTagData = ItemTagData( itemTagId: itemTagInfo.id, @@ -258,7 +258,7 @@ extension NFCManager: NFCNDEFReaderSessionDelegate { } func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {} - + func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { print( "readerSession error: \(error.localizedDescription)") } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index ac75efc..0effbb7 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -161,7 +161,12 @@ private extension MainView { func scanView() -> ScanView { .init( - itemTagRepository: dataManager.itemTagRepository + viewModel: ScanViewModel( + itemTagRepository: dataManager.itemTagRepository, + sessionController: dataManager.sessionController, + messageBus: messageBus, + nfcManager: appSingletons.nfcManager + ) ) } diff --git a/NativeAppTemplate/UI/Scan/ScanView.swift b/NativeAppTemplate/UI/Scan/ScanView.swift index ec486f6..99163c1 100644 --- a/NativeAppTemplate/UI/Scan/ScanView.swift +++ b/NativeAppTemplate/UI/Scan/ScanView.swift @@ -11,7 +11,7 @@ import CoreNFC enum ScanType: String { case completeScan case test - + var displayString: String { switch self { case .completeScan: @@ -32,7 +32,7 @@ extension ScanType: CaseIterable { self = Self.allCases[newValue] } } - + var count: Int { Self.allCases.count } @@ -44,30 +44,18 @@ extension ScanType: Identifiable { } struct ScanView: View { - @Environment(MessageBus.self) private var messageBus - @Environment(\.sessionController) private var sessionController + @Environment(\.sessionController) private var sessionController: SessionControllerProtocol @StateObject private var nfcManager = appSingletons.nfcManager - @State private var scanType: ScanType = .completeScan - @State private var isShowingResetConfirmationDialog = false - @State private var isFetching = false - @State private var isResetting = false - private let itemTagRepository: ItemTagRepositoryProtocol - - init( - itemTagRepository: ItemTagRepositoryProtocol - ) { - self.itemTagRepository = itemTagRepository + @State private var viewModel: ScanViewModel + + init(viewModel: ScanViewModel) { + self._viewModel = State(initialValue: viewModel) } - + var body: some View { contentView .onChange(of: sessionController.didBackgroundTagReading) { - Task { @MainActor in - if sessionController.didBackgroundTagReading { - sessionController.didBackgroundTagReading = false - scanType = .completeScan - } - } + viewModel.handleBackgroundTagReading() } } } @@ -76,75 +64,34 @@ struct ScanView: View { private extension ScanView { var contentView: some View { @ViewBuilder var contentView: some View { - if isFetching { + if viewModel.isBusy { LoadingView() } else { scanView .onChange(of: nfcManager.isScanResultChanged) { - guard nfcManager.isScanResultChanged else { return } - guard nfcManager.scanResult != nil else { return } - - switch nfcManager.scanResult { - case .success(let itemTagData): - completeTag(itemTagId: itemTagData.itemTagId) - case .failure(let error): - sessionController.completeScanResult = CompleteScanResult( - type: .failed, - message: error.localizedDescription - ) - default: - break - } + viewModel.handleScanResultChanged() } .onChange(of: nfcManager.isScanResultChangedForTesting) { - guard nfcManager.isScanResultChangedForTesting else { return } - guard nfcManager.scanResult != nil else { return } - - switch nfcManager.scanResult { - case .success(let itemTagData): - fetchItemTagDetail(itemTagData: itemTagData) - case .failure(let error): - sessionController.showTagInfoScanResult = ShowTagInfoScanResult( - type: .failed, - message: error.localizedDescription - ) - default: - break - } + viewModel.handleScanResultChangedForTesting() } } } - + return contentView } - + var scanView: some View { ScrollView { VStack(spacing: 64) { - switch scanType { + switch viewModel.scanType { case .completeScan: - if !isShowingResetConfirmationDialog { + if !viewModel.isShowingResetConfirmationDialog { GroupBox(label: Label(String.completeScan, systemImage: "flag.checkered") ) { MainButtonView(title: String.scan, type: .coloredPrimary(withArrow: false)) { - guard NFCNDEFReaderSession.readingAvailable else { - messageBus.post( - message: Message( - level: .error, - message: String.thisDeviceDoesNotSupportTagScanning, - autoDismiss: false - ) - ) - return - } - - sessionController.completeScanResult = CompleteScanResult() - - Task { - await nfcManager.startReading() - } + viewModel.startCompleteScan() } - .padding() - + .padding() + Text(String.completeScanHelp) .font(.uiFootnote) .foregroundStyle(.coloredPrimaryFootnoteText) @@ -152,50 +99,35 @@ private extension ScanView { .foregroundStyle(.coloredPrimaryForeground) .backgroundStyle(.coloredPrimaryBackground) } - + CompleteScanResultView( completeScanResult: sessionController.completeScanResult ) case .test: GroupBox(label: Label(String.showTagInfoScan, systemImage: "info.circle") ) { MainButtonView(title: String.scan, type: .coloredSecondary(withArrow: false)) { - guard NFCNDEFReaderSession.readingAvailable else { - messageBus.post( - message: Message( - level: .error, - message: String.thisDeviceDoesNotSupportTagScanning, - autoDismiss: false - ) - ) - return - } - - sessionController.showTagInfoScanResult = ShowTagInfoScanResult() - - Task { - await nfcManager.startReadingForTesting() - } + viewModel.startTestScan() } .padding() - + Text(String.showTagInfoScanHelp) .font(.uiFootnote) .foregroundStyle(.coloredSecondaryFootnoteText) } .foregroundStyle(.coloredSecondaryForeground) .backgroundStyle(.coloredSecondaryBackground) - + ShowTagInfoScanResultView( showTagInfoScanResult: sessionController.showTagInfoScanResult ) } - + Spacer() } } .toolbar { ToolbarItem(placement: .principal) { - Picker(String("ScanType"), selection: $scanType) { + Picker(String("ScanType"), selection: $viewModel.scanType) { Text(String.completeScan).tag(ScanType.completeScan) Text(String.showTagInfoScan).tag(ScanType.test) } @@ -207,15 +139,13 @@ private extension ScanView { .padding() .confirmationDialog( String.itemTagAlreadyCompleted, - isPresented: $isShowingResetConfirmationDialog + isPresented: $viewModel.isShowingResetConfirmationDialog ) { Button(String.reset, role: .destructive) { - if let itemTagId = sessionController.completeScanResult.itemTag?.id { - resetTag(itemTagId: itemTagId) - } + viewModel.resetTag() } Button(String.cancel, role: .cancel) { - isShowingResetConfirmationDialog = false + viewModel.dismissResetConfirmationDialog() } } message: { Text(String.areYouSure) @@ -223,72 +153,4 @@ private extension ScanView { .accessibility(identifier: "scanView") .scrollContentBackground(.hidden) } - - func completeTag(itemTagId: String) { - Task { @MainActor in - do { - let itemTag = try await itemTagRepository.complete(id: itemTagId) - - sessionController.completeScanResult = CompleteScanResult( - itemTag: itemTag, - type: .completed - ) - - if itemTag.alreadyCompleted! { - isShowingResetConfirmationDialog = true - } - } catch { - sessionController.completeScanResult = CompleteScanResult( - type: .failed, - message: error.localizedDescription - ) - } - } - } - - private func resetTag(itemTagId: String) { - Task { @MainActor in - isResetting = true - - do { - let itemTag = try await itemTagRepository.reset(id: itemTagId) - sessionController.completeScanResult = CompleteScanResult( - itemTag: itemTag, - type: .reset - ) - } catch { - sessionController.completeScanResult = CompleteScanResult( - type: .failed, - message: error.localizedDescription - ) - } - - isResetting = false - } - } - - private func fetchItemTagDetail(itemTagData: ItemTagData) { - Task { - isFetching = true - - do { - let itemTag = try await itemTagRepository.fetchDetail(id: itemTagData.itemTagId) - - sessionController.showTagInfoScanResult = ShowTagInfoScanResult( - itemTag: itemTag, - itemTagType: itemTagData.itemTagType, - isReadOnly: itemTagData.isReadOnly, - type: .succeeded, - scannedAt: itemTagData.scannedAt - ) - } catch { - sessionController.showTagInfoScanResult = ShowTagInfoScanResult( - type: .failed, - message: error.localizedDescription - ) - } - - isFetching = false - } - } } diff --git a/NativeAppTemplate/UI/Scan/ScanViewModel.swift b/NativeAppTemplate/UI/Scan/ScanViewModel.swift new file mode 100644 index 0000000..a151f24 --- /dev/null +++ b/NativeAppTemplate/UI/Scan/ScanViewModel.swift @@ -0,0 +1,196 @@ +// +// ScanViewModel.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/15. +// + +import SwiftUI +import Observation +import CoreNFC + +@Observable +@MainActor +final class ScanViewModel { + var scanType: ScanType = .completeScan + var isShowingResetConfirmationDialog = false + var isFetching = false + var isResetting = false + + private let itemTagRepository: ItemTagRepositoryProtocol + private let sessionController: SessionControllerProtocol + private let messageBus: MessageBus + private let nfcManager: NFCManagerProtocol + + init( + itemTagRepository: ItemTagRepositoryProtocol, + sessionController: SessionControllerProtocol, + messageBus: MessageBus, + nfcManager: NFCManagerProtocol + ) { + self.itemTagRepository = itemTagRepository + self.sessionController = sessionController + self.messageBus = messageBus + self.nfcManager = nfcManager + } + + var isBusy: Bool { + isFetching || isResetting + } + + func handleBackgroundTagReading() { + if sessionController.didBackgroundTagReading { + sessionController.didBackgroundTagReading = false + scanType = .completeScan + } + } + + func handleScanResultChanged() { + guard nfcManager.isScanResultChanged else { return } + guard nfcManager.scanResult != nil else { return } + + switch nfcManager.scanResult { + case .success(let itemTagData): + completeTag(itemTagId: itemTagData.itemTagId) + case .failure(let error): + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + default: + break + } + } + + func handleScanResultChangedForTesting() { + guard nfcManager.isScanResultChangedForTesting else { return } + guard nfcManager.scanResult != nil else { return } + + switch nfcManager.scanResult { + case .success(let itemTagData): + fetchItemTagDetail(itemTagData: itemTagData) + case .failure(let error): + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + type: .failed, + message: error.localizedDescription + ) + default: + break + } + } + + func startCompleteScan() { + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + sessionController.completeScanResult = CompleteScanResult() + + Task { + await nfcManager.startReading() + } + } + + func startTestScan() { + guard NFCNDEFReaderSession.readingAvailable else { + messageBus.post( + message: Message( + level: .error, + message: String.thisDeviceDoesNotSupportTagScanning, + autoDismiss: false + ) + ) + return + } + + sessionController.showTagInfoScanResult = ShowTagInfoScanResult() + + Task { + await nfcManager.startReadingForTesting() + } + } + + func resetTag() { + guard let itemTagId = sessionController.completeScanResult.itemTag?.id else { return } + resetTag(itemTagId: itemTagId) + } + + func dismissResetConfirmationDialog() { + isShowingResetConfirmationDialog = false + } + + private func completeTag(itemTagId: String) { + Task { + do { + let itemTag = try await itemTagRepository.complete(id: itemTagId) + + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .completed + ) + + if itemTag.alreadyCompleted! { + isShowingResetConfirmationDialog = true + } + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + } + } + + private func resetTag(itemTagId: String) { + Task { + isResetting = true + + do { + let itemTag = try await itemTagRepository.reset(id: itemTagId) + sessionController.completeScanResult = CompleteScanResult( + itemTag: itemTag, + type: .reset + ) + } catch { + sessionController.completeScanResult = CompleteScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + isResetting = false + } + } + + private func fetchItemTagDetail(itemTagData: ItemTagData) { + Task { + isFetching = true + + do { + let itemTag = try await itemTagRepository.fetchDetail(id: itemTagData.itemTagId) + + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + itemTag: itemTag, + itemTagType: itemTagData.itemTagType, + isReadOnly: itemTagData.isReadOnly, + type: .succeeded, + scannedAt: itemTagData.scannedAt + ) + } catch { + sessionController.showTagInfoScanResult = ShowTagInfoScanResult( + type: .failed, + message: error.localizedDescription + ) + } + + isFetching = false + } + } +} diff --git a/NativeAppTemplateTests/UI/Scan/ScanViewModelTest.swift b/NativeAppTemplateTests/UI/Scan/ScanViewModelTest.swift new file mode 100644 index 0000000..5174d23 --- /dev/null +++ b/NativeAppTemplateTests/UI/Scan/ScanViewModelTest.swift @@ -0,0 +1,483 @@ +// +// ScanViewModelTest.swift +// NativeAppTemplate +// +// Created by Daisuke Adachi on 2025/06/20. +// + +// swiftlint:disable file_length + +import Testing +import Foundation +@testable import NativeAppTemplate + +@MainActor +@Suite +// swiftlint:disable:next type_body_length +struct ScanViewModelTest { + let itemTagRepository = TestItemTagRepository(itemTagsService: ItemTagsService()) + let sessionController = TestSessionController() + let messageBus = MessageBus() + let nfcManager = TestNFCManager() + + var testItemTag: ItemTag { + var tag = ItemTag() + tag.id = "test-tag-id" + tag.shopId = "test-shop-id" + tag.queueNumber = "123" + tag.state = .idled + tag.completedAt = nil + tag.alreadyCompleted = false + return tag + } + + var testItemTagData: ItemTagData { + ItemTagData( + itemTagId: "test-tag-id", + itemTagType: .server, + isReadOnly: false, + scannedAt: Date.now + ) + } + + @Test + func initializesCorrectly() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + #expect(viewModel.scanType == ScanType.completeScan) + #expect(viewModel.isShowingResetConfirmationDialog == false) + #expect(viewModel.isFetching == false) + #expect(viewModel.isResetting == false) + #expect(viewModel.isBusy == false) + } + + @Test + func busyStateReflectsFetchingAndResettingStates() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + #expect(viewModel.isBusy == false) + + viewModel.isFetching = true + #expect(viewModel.isBusy == true) + + viewModel.isFetching = false + viewModel.isResetting = true + #expect(viewModel.isBusy == true) + + viewModel.isResetting = false + #expect(viewModel.isBusy == false) + + // Both fetching and resetting + viewModel.isFetching = true + viewModel.isResetting = true + #expect(viewModel.isBusy == true) + } + + @Test + func handleBackgroundTagReadingUpdatesScanType() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + sessionController.didBackgroundTagReading = false + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + viewModel.scanType = ScanType.test + viewModel.handleBackgroundTagReading() + #expect(viewModel.scanType == ScanType.test) // Should not change + #expect(sessionController.didBackgroundTagReading == false) + + sessionController.didBackgroundTagReading = true + viewModel.handleBackgroundTagReading() + #expect(viewModel.scanType == ScanType.completeScan) + #expect(sessionController.didBackgroundTagReading == false) + } + + @Test + func handleScanResultChangedWithSuccessCompletesTag() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + nfcManager.reset() + itemTagRepository.error = nil + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup successful scan result + nfcManager.simulatedItemTagData = testItemTagData + nfcManager.shouldSimulateSuccess = true + nfcManager.simulateScanResult() + + viewModel.handleScanResultChanged() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.completeScanResult.type == .completed) + #expect(sessionController.completeScanResult.itemTag?.id == "test-tag-id") + } + + @Test + func handleScanResultChangedWithFailureSetsError() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup failed scan result + let testError = NSError(domain: "TestError", code: 123, userInfo: [NSLocalizedDescriptionKey: "Test scan error"]) + nfcManager.simulatedError = testError + nfcManager.shouldSimulateSuccess = false + nfcManager.simulateScanResult() + + viewModel.handleScanResultChanged() + + #expect(sessionController.completeScanResult.type == .failed) + #expect(sessionController.completeScanResult.message == "Test scan error") + } + + @Test + func handleScanResultChangedWithoutChangedResultDoesNothing() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + nfcManager.isScanResultChanged = false + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + let originalResult = sessionController.completeScanResult + viewModel.handleScanResultChanged() + + #expect(sessionController.completeScanResult.type == originalResult.type) + } + + @Test + func handleScanResultChangedForTestingWithSuccessFetchesDetail() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + itemTagRepository.error = nil + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup successful scan result for testing + nfcManager.simulatedItemTagData = testItemTagData + nfcManager.shouldSimulateSuccess = true + nfcManager.simulateScanResultForTesting() + + viewModel.handleScanResultChangedForTesting() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.showTagInfoScanResult.type == .succeeded) + #expect(sessionController.showTagInfoScanResult.itemTag?.id == "test-tag-id") + #expect(sessionController.showTagInfoScanResult.itemTagType == .server) + #expect(sessionController.showTagInfoScanResult.isReadOnly == false) + } + + @Test + func handleScanResultChangedForTestingWithFailureSetsError() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup failed scan result for testing + let testError = NSError(domain: "TestError", code: 456, userInfo: [NSLocalizedDescriptionKey: "Test fetch error"]) + nfcManager.simulatedError = testError + nfcManager.shouldSimulateSuccess = false + nfcManager.simulateScanResultForTesting() + + viewModel.handleScanResultChangedForTesting() + + #expect(sessionController.showTagInfoScanResult.type == .failed) + #expect(sessionController.showTagInfoScanResult.message == "Test fetch error") + } + + @Test + func startCompleteScanInitializesResultAndStartsNFC() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + let startCompleteScanTask = Task { + viewModel.startCompleteScan() + } + await startCompleteScanTask.value + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.completeScanResult.type == .idled) + #expect(nfcManager.readingStarted) + } + + @Test + func startTestScanInitializesResultAndStartsNFC() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + nfcManager.reset() + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + let startTestScanTask = Task { + viewModel.startTestScan() + } + await startTestScanTask.value + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.showTagInfoScanResult.type == .idled) + #expect(nfcManager.testingStarted) + } + + @Test + func resetTagWithValidItemTagResetsSuccessfully() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + itemTagRepository.error = nil + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup a completed scan result with item tag + sessionController.completeScanResult = CompleteScanResult( + itemTag: testItemTag, + type: .completed + ) + + viewModel.resetTag() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.completeScanResult.type == .reset) + #expect(sessionController.completeScanResult.itemTag?.id == "test-tag-id") + } + + @Test + func resetTagWithoutItemTagDoesNothing() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup scan result without item tag + sessionController.completeScanResult = CompleteScanResult(type: .idled) + let originalResult = sessionController.completeScanResult + + viewModel.resetTag() + + #expect(sessionController.completeScanResult.type == originalResult.type) + } + + @Test + func resetTagWithFailureUpdatesResult() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + itemTagRepository.error = NativeAppTemplateAPIError.requestFailed(nil, 500, "Reset failed") + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup a completed scan result with item tag + sessionController.completeScanResult = CompleteScanResult( + itemTag: testItemTag, + type: .completed + ) + + viewModel.resetTag() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(sessionController.completeScanResult.type == .failed) + #expect(sessionController.completeScanResult.message.contains("Reset failed")) + } + + @Test + func dismissResetConfirmationDialogUpdatesState() { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + viewModel.isShowingResetConfirmationDialog = true + #expect(viewModel.isShowingResetConfirmationDialog == true) + + viewModel.dismissResetConfirmationDialog() + #expect(viewModel.isShowingResetConfirmationDialog == false) + } + + @Test + func completeTagWithAlreadyCompletedShowsDialog() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + itemTagRepository.error = nil + + var alreadyCompletedTag = ItemTag() + alreadyCompletedTag.id = "completed-tag-id" + alreadyCompletedTag.shopId = "test-shop-id" + alreadyCompletedTag.queueNumber = "456" + alreadyCompletedTag.state = .completed + alreadyCompletedTag.completedAt = Date.now + alreadyCompletedTag.alreadyCompleted = true + + // Add the already completed tag to repository + itemTagRepository.itemTags.append(alreadyCompletedTag) + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup scan result for already completed tag + let completedTagData = ItemTagData( + itemTagId: "completed-tag-id", + itemTagType: .server, + isReadOnly: false, + scannedAt: Date.now + ) + + nfcManager.simulatedItemTagData = completedTagData + nfcManager.shouldSimulateSuccess = true + nfcManager.simulateScanResult() + + viewModel.handleScanResultChanged() + + // Wait for async task to complete + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + #expect(viewModel.isShowingResetConfirmationDialog == true) + #expect(sessionController.completeScanResult.type == .completed) + } + + @Test + func busyStateDuringReset() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + itemTagRepository.error = nil + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup a completed scan result with item tag + sessionController.completeScanResult = CompleteScanResult( + itemTag: testItemTag, + type: .completed + ) + + let resetTask = Task { + viewModel.resetTag() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isResetting) + + await resetTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isResetting == false) + } + + @Test + func busyStateDuringFetch() async { + itemTagRepository.setItemTags(itemTags: [testItemTag]) + itemTagRepository.error = nil + nfcManager.reset() + + let viewModel = ScanViewModel( + itemTagRepository: itemTagRepository, + sessionController: sessionController, + messageBus: messageBus, + nfcManager: nfcManager + ) + + // Setup successful scan result for testing + nfcManager.simulatedItemTagData = testItemTagData + nfcManager.shouldSimulateSuccess = true + nfcManager.simulateScanResultForTesting() + + let fetchTask = Task { + viewModel.handleScanResultChangedForTesting() + } + + // Check busy state immediately after starting + #expect(viewModel.isBusy == viewModel.isFetching) + + await fetchTask.value + + #expect(viewModel.isBusy == false) + #expect(viewModel.isFetching == false) + } +}