From f7ba803ca2e90cbdc99cf2bb4db22e49fb220af9 Mon Sep 17 00:00:00 2001 From: rimtty Date: Mon, 7 Sep 2026 21:55:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(ios-scanner):=20lock,=20count=20and=20form?= =?UTF-8?q?at=20=E3=83=87=E3=83=B3=E3=82=BD=E3=83=BC=20kanbans=20in=20the?= =?UTF-8?q?=20scan=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view model formats part numbers with the session's destination (6-4 for Denso, 4-2-4 / 4-2-3 otherwise) in the accepted-QR message, the result view and the recorded code, adds the Denso sample payloads and simulator-only mock buttons, and documents that Denso shares the QR-only duplicate rule and per-part box count with Sawai. Flow, store and UI tests cover match, second kanban, duplicate rescan, the destination lock in both directions and the 6-4 tag rejection. Refs #108 --- .../Features/Scanner/ScannerScreen.swift | 27 +- .../Features/Scanner/ScannerViewModel.swift | 22 +- ios/CodeMatch/Resources/Localizable.xcstrings | 32 +++ ios/CodeMatch/Services/HistoryStore.swift | 5 +- .../BluetoothScannerFlowTests.swift | 264 ++++++++++++++++++ ios/CodeMatchTests/HistoryStoreTests.swift | 75 +++++ ios/CodeMatchUITests/CodeMatchUITests.swift | 56 ++++ 7 files changed, 473 insertions(+), 8 deletions(-) diff --git a/ios/CodeMatch/Features/Scanner/ScannerScreen.swift b/ios/CodeMatch/Features/Scanner/ScannerScreen.swift index 64a4671..769c6a0 100644 --- a/ios/CodeMatch/Features/Scanner/ScannerScreen.swift +++ b/ios/CodeMatch/Features/Scanner/ScannerScreen.swift @@ -260,8 +260,12 @@ struct ScannerScreen: View { result: result, sessionBoxNumber: viewModel.sessionBoxNumber, deliverySummary: viewModel.deliverySummary, - qrPartNumber: viewModel.qrPartNumber.map { CodeMatcher.format(partNumber: $0) }, - barcodePartNumber: viewModel.barcodePartNumber.map { CodeMatcher.format(partNumber: $0) } + qrPartNumber: viewModel.qrPartNumber.map { + CodeMatcher.format(partNumber: $0, destination: viewModel.destination) + }, + barcodePartNumber: viewModel.barcodePartNumber.map { + CodeMatcher.format(partNumber: $0, destination: viewModel.destination) + } ) .accessibilityIdentifier("resultView") case .qr, .barcode: @@ -452,12 +456,16 @@ struct ScannerScreen: View { VStack(spacing: 8) { CodeReadout( label: AppLocalization.string("QR・納品書の品目番号"), - partNumber: viewModel.qrPartNumber.map { CodeMatcher.format(partNumber: $0) }, + partNumber: viewModel.qrPartNumber.map { + CodeMatcher.format(partNumber: $0, destination: viewModel.destination) + }, payload: viewModel.qrValue ) CodeReadout( label: AppLocalization.string("バーコード・現品票の品番"), - partNumber: viewModel.barcodePartNumber.map { CodeMatcher.format(partNumber: $0) }, + partNumber: viewModel.barcodePartNumber.map { + CodeMatcher.format(partNumber: $0, destination: viewModel.destination) + }, payload: viewModel.barcodeValue ) } @@ -641,6 +649,17 @@ struct ScannerScreen: View { } .accessibilityIdentifier("demoBluetoothMoltenBarcodeButton") } + + HStack { + Button(AppLocalization.string("モックQR(デンソー)")) { + bluetoothScanner.simulateScan(ScannerViewModel.sampleDensoQRPayload) + } + .accessibilityIdentifier("demoBluetoothDensoQRButton") + Button(AppLocalization.string("モックCode 128(デンソー)")) { + bluetoothScanner.simulateScan(ScannerViewModel.sampleDensoBarcodePayload) + } + .accessibilityIdentifier("demoBluetoothDensoBarcodeButton") + } } } .buttonStyle(.borderedProminent) diff --git a/ios/CodeMatch/Features/Scanner/ScannerViewModel.swift b/ios/CodeMatch/Features/Scanner/ScannerViewModel.swift index c480dad..739746e 100644 --- a/ios/CodeMatch/Features/Scanner/ScannerViewModel.swift +++ b/ios/CodeMatch/Features/Scanner/ScannerViewModel.swift @@ -79,6 +79,16 @@ final class ScannerViewModel: ObservableObject { /// 同じ納品書QRの2箱目。モルテンのQRは箱を区別しないため、現品票の管理コードだけが異なる。 static let sampleMoltenSecondBoxBarcodePayload = "PAF1-15-422@0NLL3C" static let sampleMoltenMismatchBarcodePayload = "PAF1-15-423@0N5L3C" + /// 実ラベル由来のサンプルペイロード(仕向地 デンソー・品番 860150-7722)。 + /// JAMA自己記述形式のかんばん。かんばん連番(項目152)が箱ごとに異なるため、 + /// 重複判定は澤井製作所と同じくQR全文で行う。 + static let sampleDensoQRPayload = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + static let sampleDensoBarcodePayload = "860150-7722@1DZ50O" + /// 同じ品番の2箱目。かんばん連番が0140から0141に変わる。 + static let sampleDensoSecondBoxQRPayload = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + static let sampleDensoSecondBoxBarcodePayload = "860150-7722@1DZB0O" + /// 別品番(860150-7791)の現品票。不一致の確認に使う。 + static let sampleDensoMismatchBarcodePayload = "860150-7791@01335C" var qrPartNumber: String? { CodeMatcher.partNumber(fromQR: qrValue) @@ -445,7 +455,9 @@ final class ScannerViewModel: ObservableObject { camera.setActiveType(ExpectedCode.barcode.metadataType) } if let part = CodeMatcher.partNumber(fromQR: value) { - let partNumber = CodeMatcher.format(partNumber: part) + // 表記は仕向地ごとに違う(デンソーは6-4、それ以外は4-2-4/4-2-3)。 + // ここでは受理したQRの仕向地をそのまま使う。 + let partNumber = CodeMatcher.format(partNumber: part, destination: destination) let isBluetooth = inputSource == .bluetooth setLocalizedMessage { let instruction = isBluetooth @@ -686,6 +698,8 @@ final class ScannerViewModel: ObservableObject { ) } } else { + // 澤井製作所とデンソーはQR自体が箱ごとに異なる(カード番号/かんばん連番)ため、 + // 納品番号での集計は使わず、品番ごとに「本セッションでN箱目」を数える。 deliverySummary = nil let boxNumber = historyStore.activeSessionMatchCount(code: recordedCode) sessionBoxNumber = boxNumber @@ -751,9 +765,13 @@ final class ScannerViewModel: ObservableObject { } /// 履歴へ残す値。読み取れた品番を優先し、抽出できない場合はQRの生値を使う。 + /// 表記は仕向地ごとに違う(デンソーは6-4、それ以外は4-2-4/4-2-3)ため、 + /// セッションで固定済みの仕向地を使う。デモ判定などQRの受理を経ない経路では + /// まだ固定されていないので、そのQRから判定し直す。 private var recordedCode: String { if let part = barcodePartNumber ?? qrPartNumber { - return CodeMatcher.format(partNumber: part) + let destination = destination ?? Destination.detect(qrPayload: qrValue) + return CodeMatcher.format(partNumber: part, destination: destination) } return qrValue } diff --git a/ios/CodeMatch/Resources/Localizable.xcstrings b/ios/CodeMatch/Resources/Localizable.xcstrings index 5a23a94..b22b84f 100644 --- a/ios/CodeMatch/Resources/Localizable.xcstrings +++ b/ios/CodeMatch/Resources/Localizable.xcstrings @@ -2492,6 +2492,22 @@ } } }, + "モックCode 128(デンソー)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mock Code 128 (Denso)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モックCode 128(デンソー)" + } + } + } + }, "モックCode 128(モルテン)" : { "localizations" : { "en" : { @@ -2508,6 +2524,22 @@ } } }, + "モックQR(デンソー)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mock QR (Denso)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モックQR(デンソー)" + } + } + } + }, "モックQR(モルテン)" : { "localizations" : { "en" : { diff --git a/ios/CodeMatch/Services/HistoryStore.swift b/ios/CodeMatch/Services/HistoryStore.swift index 1996c8d..b5cbe65 100644 --- a/ios/CodeMatch/Services/HistoryStore.swift +++ b/ios/CodeMatch/Services/HistoryStore.swift @@ -93,8 +93,9 @@ final class HistoryStore: ObservableObject { } /// アクティブセッションの成功履歴に、今回の箱がすでに含まれるかを確認する。 - /// 澤井製作所のQRは箱ごとにカード番号が異なるためQR全文だけで箱を識別できるが、 - /// モルテンのQRは同じ納品番号の全箱で同一なので、現品票のCode 128全文まで含めて識別する。 + /// 澤井製作所のQRは箱ごとにカード番号が、デンソーのかんばんQRは箱ごとにかんばん連番 + /// (項目152)が異なるため、どちらもQR全文だけで箱を識別できる。 + /// モルテンのQRだけは同じ納品番号の全箱で同一なので、現品票のCode 128全文まで含めて識別する。 func activeSessionContainsMatchedBox(qrPayload: String, barcodePayload: String) -> Bool { guard Destination.detect(qrPayload: qrPayload) == .molten else { return activeSessionContainsMatchedQRPayload(qrPayload) diff --git a/ios/CodeMatchTests/BluetoothScannerFlowTests.swift b/ios/CodeMatchTests/BluetoothScannerFlowTests.swift index 6ea6d54..2d2a8bf 100644 --- a/ios/CodeMatchTests/BluetoothScannerFlowTests.swift +++ b/ios/CodeMatchTests/BluetoothScannerFlowTests.swift @@ -1014,6 +1014,270 @@ final class BluetoothScannerFlowTests: XCTestCase { ) } + func testDensoQRThenSixFourBarcodeMatches() async { + let context = makeContext() + defer { context.cleanup() } + // JAMA自己記述形式のかんばん。実物は221桁。 + XCTAssertEqual(ScannerViewModel.sampleDensoQRPayload.count, 221) + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, .denso) + XCTAssertEqual(context.store.activeSession?.destination, .denso) + // デンソーの品番は6-4で表示する(4-2-4の8601-50-7722にしない)。 + XCTAssertTrue(context.viewModel.message.contains("品目番号 860150-7722")) + + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleDensoBarcodePayload) + + XCTAssertEqual(context.viewModel.step, .result(.match)) + XCTAssertEqual(context.store.activeSession?.matchedCount, 1) + XCTAssertEqual(context.viewModel.sessionBoxNumber, 1) + // 納品番号ごとの集計はモルテン専用。デンソーでは使わない。 + XCTAssertNil(context.viewModel.deliverySummary) + XCTAssertEqual(context.store.activeSession?.entries.first?.code, "860150-7722") + XCTAssertTrue(context.viewModel.message.contains("品目番号が一致しています")) + } + + func testDensoSecondKanbanSamePartIsCountedAsSecondBox() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleDensoBarcodePayload) + XCTAssertEqual(context.viewModel.step, .result(.match)) + + // かんばん連番が0140→0141と変わるので、同じ品番でも別の箱として数える。 + context.viewModel.reset() + try? await Task.sleep(for: .milliseconds(800)) + context.service.simulateScan(ScannerViewModel.sampleDensoSecondBoxQRPayload) + XCTAssertEqual(context.viewModel.step, .barcode) + try? await Task.sleep(for: .milliseconds(800)) + context.service.simulateScan(ScannerViewModel.sampleDensoSecondBoxBarcodePayload) + + XCTAssertEqual(context.viewModel.step, .result(.match)) + XCTAssertEqual(context.store.activeSession?.matchedCount, 2) + XCTAssertEqual(context.viewModel.sessionBoxNumber, 2) + XCTAssertNil(context.viewModel.deliverySummary) + XCTAssertTrue(context.viewModel.message.contains("2箱目")) + } + + func testDensoRescanOfSameKanbanIsDuplicate() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleDensoBarcodePayload) + XCTAssertEqual(context.viewModel.step, .result(.match)) + XCTAssertEqual(context.store.activeSession?.matchedCount, 1) + + // 重複キーはQR全文。現品票の管理コードが別でも、同じかんばんなら同じ箱。 + context.viewModel.reset() + try? await Task.sleep(for: .milliseconds(800)) + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + XCTAssertEqual(context.viewModel.step, .barcode) + try? await Task.sleep(for: .milliseconds(800)) + context.service.simulateScan(ScannerViewModel.sampleDensoSecondBoxBarcodePayload) + + XCTAssertEqual(context.viewModel.step, .result(.duplicate)) + XCTAssertEqual(context.store.activeSession?.matchedCount, 1) + XCTAssertEqual(context.viewModel.sessionBoxNumber, 0) + XCTAssertNil(context.viewModel.deliverySummary) + } + + func testDensoMismatchIsNotCounted() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + try? await Task.sleep(for: .milliseconds(300)) + // 同じ6-4の並びでも末尾が違えば別品番として扱う。 + context.service.simulateScan(ScannerViewModel.sampleDensoMismatchBarcodePayload) + + XCTAssertEqual(context.viewModel.step, .result(.mismatch)) + XCTAssertEqual(context.store.activeSession?.matchedCount, 0) + XCTAssertNil(context.viewModel.deliverySummary) + XCTAssertEqual(context.viewModel.destination, .denso) + } + + func testSessionLockedToDensoRejectsSawaiAndMoltenQR() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, .denso) + + context.viewModel.rereadQR() + XCTAssertEqual(context.viewModel.step, .qr) + XCTAssertEqual(context.viewModel.destination, .denso) + + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleQRPayload) + + XCTAssertEqual(context.viewModel.step, .qr) + XCTAssertTrue(context.viewModel.qrValue.isEmpty) + XCTAssertTrue(context.viewModel.message.contains("デンソー")) + XCTAssertTrue( + context.viewModel.message.contains( + "このセッションは仕向地「デンソー」で照合中です。別の仕向地のQRコードは照合できません。仕向地を変えるにはセッションを終了してください。" + ) + ) + XCTAssertTrue(context.viewModel.message.contains("読み取った値は照合に使用していません")) + + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleMoltenQRPayload) + + XCTAssertEqual(context.viewModel.step, .qr) + XCTAssertTrue(context.viewModel.qrValue.isEmpty) + XCTAssertTrue(context.viewModel.message.contains("デンソー")) + XCTAssertEqual(context.store.activeSession?.matchedCount, 0) + + // 同じ仕向地のQRはそのまま受理する。 + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, .denso) + } + + func testSawaiSessionRejectsDensoQR() { + let context = makeContext() + defer { context.cleanup() } + let camera = context.viewModel.camera + XCTAssertEqual(context.viewModel.inputSource, .camera) + + context.viewModel.cameraScanner(camera, didRead: ScannerViewModel.sampleQRPayload, type: .qr) + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, .sawai) + + context.viewModel.rereadQR() + XCTAssertEqual(context.viewModel.step, .qr) + + context.viewModel.cameraScanner( + camera, + didRead: ScannerViewModel.sampleDensoQRPayload, + type: .qr + ) + + XCTAssertEqual(context.viewModel.step, .qr) + XCTAssertTrue(context.viewModel.qrValue.isEmpty) + XCTAssertTrue(context.viewModel.message.contains("澤井製作所")) + XCTAssertEqual(context.viewModel.destination, .sawai) + XCTAssertEqual(context.store.activeSession?.matchedCount, 0) + } + + func testSixFourBarcodeIsRejectedInSawaiAndMoltenSessions() async { + // 澤井製作所は4-2-4、モルテンは4-2-3/4-2-4。デンソーの6-4はどちらでも受理しない。 + for (qrPayload, destination) in [ + (ScannerViewModel.sampleQRPayload, Destination.sawai), + (ScannerViewModel.sampleMoltenQRPayload, Destination.molten) + ] { + let context = makeContext() + defer { context.cleanup() } + let camera = context.viewModel.camera + + context.viewModel.cameraScanner(camera, didRead: qrPayload, type: .qr) + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, destination) + try? await Task.sleep(for: .milliseconds(300)) + + context.viewModel.cameraScanner( + camera, + didRead: ScannerViewModel.sampleDensoBarcodePayload, + type: .code128 + ) + context.viewModel.cameraScanner( + camera, + didRead: ScannerViewModel.sampleDensoBarcodePayload, + type: .code128 + ) + + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertTrue(context.viewModel.barcodeValue.isEmpty) + XCTAssertTrue( + context.viewModel.message.contains("現品票のCode 128バーコードではありません") + ) + XCTAssertEqual(context.store.activeSession?.matchedCount, 0) + } + } + + func testDensoQRAtBarcodeStepIsWrongOrder() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + XCTAssertEqual(context.viewModel.step, .barcode) + + // 別のかんばん(同じ仕向地)を現品票の代わりに読んだ場合は順序違いとして案内する。 + try? await Task.sleep(for: .milliseconds(800)) + context.service.simulateScan(ScannerViewModel.sampleDensoSecondBoxQRPayload) + + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertTrue(context.viewModel.barcodeValue.isEmpty) + XCTAssertTrue(context.viewModel.message.contains("読み取り順序が違います")) + XCTAssertEqual(context.store.activeSession?.matchedCount, 0) + } + + func testDestinationIsRestoredFromActiveSessionForDenso() async { + let context = makeContext() + defer { context.cleanup() } + context.service.startDiscovery() + context.service.connect(context.service.devices[0]) + context.viewModel.handleBluetoothConnectionState(context.service.state) + + context.service.simulateScan(ScannerViewModel.sampleDensoQRPayload) + try? await Task.sleep(for: .milliseconds(300)) + context.service.simulateScan(ScannerViewModel.sampleDensoBarcodePayload) + XCTAssertEqual(context.store.activeSession?.destination, .denso) + + let restored = ScannerViewModel( + historyStore: context.store, + bluetoothScanner: context.service, + camera: CameraScanner() + ) + + XCTAssertEqual(restored.destination, .denso) + } + + /// カメラ経路でもデンソーのQRを受理し、案内に6-4の品番を出す。 + func testDensoQRViaCameraShowsSixFourPartNumber() { + let context = makeContext() + defer { context.cleanup() } + let camera = context.viewModel.camera + XCTAssertEqual(context.viewModel.inputSource, .camera) + + context.viewModel.cameraScanner( + camera, + didRead: ScannerViewModel.sampleDensoQRPayload, + type: .qr + ) + + XCTAssertEqual(context.viewModel.step, .barcode) + XCTAssertEqual(context.viewModel.destination, .denso) + XCTAssertTrue(context.viewModel.message.contains("品目番号 860150-7722")) + } + private func makeContext( camera: CameraScanner = CameraScanner(), autoAdvanceEnabled: Bool = false, diff --git a/ios/CodeMatchTests/HistoryStoreTests.swift b/ios/CodeMatchTests/HistoryStoreTests.swift index b73ae65..2c7b55a 100644 --- a/ios/CodeMatchTests/HistoryStoreTests.swift +++ b/ios/CodeMatchTests/HistoryStoreTests.swift @@ -21,6 +21,13 @@ final class HistoryStoreTests: XCTestCase { private let sawaiQR = "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" private let sawaiOtherCardQR = "DCLP675301BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" private let sawaiTag = "BCJH-52-81GG@1N5X0C" + // デンソーのかんばんQR(JAMA自己記述形式・221桁)。項目152のかんばん連番が箱ごとに異なる。 + private let densoQRKanban0140 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private let densoQRKanban0141 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + private let densoQRKanban0538 = "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507791000000192D860C01008D86045M 0538SWS 20260908S0010007680000009924543420454342R6" + private let densoTag0140 = "860150-7722@1DZ50O" + private let densoTag0141 = "860150-7722@1DZB0O" + private let densoTag0538 = "860150-7791@01335C" func testSessionRecordsNormalizedMatchesAndEnds() { let storageURL = temporaryStorageURL() @@ -303,6 +310,74 @@ final class HistoryStoreTests: XCTestCase { ) } + /// デンソーの重複判定もQR全文だけで決まる。かんばん連番が箱ごとに異なるため。 + func testDensoDuplicateRuleKeysOnQROnly() { + let storageURL = temporaryStorageURL() + defer { try? FileManager.default.removeItem(at: storageURL.deletingLastPathComponent()) } + XCTAssertEqual(Destination.detect(qrPayload: densoQRKanban0140), .denso) + XCTAssertEqual(Destination.detect(qrPayload: densoQRKanban0141), .denso) + let store = HistoryStore(storageURL: storageURL) + store.beginSession() + + store.recordMatch( + code: "860150-7722", + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0140 + ) + + // 現品票の管理コードが別でも、同じかんばんなら照合済みの箱 + XCTAssertTrue( + store.activeSessionContainsMatchedBox( + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0141 + ) + ) + XCTAssertTrue(store.activeSessionContainsMatchedQRPayload(densoQRKanban0140)) + // かんばん連番が違えば、同じ品番でも別の箱 + XCTAssertFalse( + store.activeSessionContainsMatchedBox( + qrPayload: densoQRKanban0141, + barcodePayload: densoTag0140 + ) + ) + } + + /// デンソーは納品番号ではなく品番ごとに箱を数える(澤井製作所と同じ規則)。 + func testDensoMatchCountPerPartNumber() { + let storageURL = temporaryStorageURL() + defer { try? FileManager.default.removeItem(at: storageURL.deletingLastPathComponent()) } + let store = HistoryStore(storageURL: storageURL) + store.beginSession() + + store.recordMatch( + code: "860150-7722", + qrPayload: densoQRKanban0140, + barcodePayload: densoTag0140 + ) + store.recordMatch( + code: "860150-7722", + qrPayload: densoQRKanban0141, + barcodePayload: densoTag0141 + ) + store.recordMatch( + code: "860150-7791", + qrPayload: densoQRKanban0538, + barcodePayload: densoTag0538 + ) + + XCTAssertEqual(store.activeSessionMatchCount(code: "860150-7722"), 2) + XCTAssertEqual(store.activeSessionMatchCount(code: "860150-7791"), 1) + XCTAssertEqual(store.activeSession?.matchedCount, 3) + XCTAssertEqual(store.activeSession?.destination, .denso) + // 品番ごとに1グループ、その中に箱の記録が並ぶ(澤井製作所と同じ構造)。 + XCTAssertEqual(store.activeSession?.groupedEntries.count, 2) + // 納品番号ごとの内訳はモルテン専用。デンソーのグループでは空のまま。 + XCTAssertEqual( + store.activeSession?.groupedEntries.map(\.deliveryGroups.count), + [0, 0] + ) + } + /// 納品番号ごとの箱数と累計数量(収容数の合計)を集計する。 func testDeliverySummaryCountsBoxesAndQuantity() { let storageURL = temporaryStorageURL() diff --git a/ios/CodeMatchUITests/CodeMatchUITests.swift b/ios/CodeMatchUITests/CodeMatchUITests.swift index 30f77da..5ff9b7f 100644 --- a/ios/CodeMatchUITests/CodeMatchUITests.swift +++ b/ios/CodeMatchUITests/CodeMatchUITests.swift @@ -182,6 +182,62 @@ final class CodeMatchUITests: XCTestCase { XCTAssertTrue(deliveryNumber.waitForExistence(timeout: 3)) } + func testMockBluetoothScannerDensoFlowLocksDestination() { + let app = launchApp(["-resetHistory", "-resetAutoAdvance", "-demoBluetoothConnected"]) + + app.buttons["startSessionButton"].tap() + let inputPicker = app.segmentedControls["scanInputSourcePicker"] + XCTAssertTrue(inputPicker.waitForExistence(timeout: 5)) + XCTAssertTrue(inputPicker.buttons["Bluetooth"].isSelected) + XCTAssertTrue(app.staticTexts["1 四角いQRコード"].waitForExistence(timeout: 3)) + + app.swipeUp() + let demoToggle = app.staticTexts["カメラなしで判定をテスト"] + XCTAssertTrue(demoToggle.waitForExistence(timeout: 3)) + demoToggle.tap() + + let densoQRButton = app.buttons["demoBluetoothDensoQRButton"] + var qrScrollAttempts = 0 + while !(densoQRButton.exists && densoQRButton.isHittable), qrScrollAttempts < 5 { + app.swipeUp() + qrScrollAttempts += 1 + } + XCTAssertTrue(densoQRButton.waitForExistence(timeout: 3)) + densoQRButton.tap() + XCTAssertTrue(app.staticTexts["2 横長のCode 128"].waitForExistence(timeout: 3)) + + app.buttons["demoBluetoothDensoBarcodeButton"].tap() + XCTAssertTrue(app.staticTexts["一致しました"].waitForExistence(timeout: 5)) + XCTAssertEqual(app.staticTexts["sessionMatchCount"].label, "1件照合済み") + XCTAssertEqual(app.staticTexts["sessionDestination"].label, "デンソー") + + // 仕向地はセッション終了まで固定され、別の仕向地のQRは照合へ進めない。 + app.buttons["resetButton"].tap() + let scannerTitle = app.staticTexts["scannerTitle"] + let backToQRStep = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label == %@", "QRコードを読み取る"), + object: scannerTitle + ) + XCTAssertEqual(XCTWaiter.wait(for: [backToQRStep], timeout: 5), .completed) + + let sawaiQRButton = app.buttons["demoBluetoothQRButton"] + var scrollAttempts = 0 + while !(sawaiQRButton.exists && sawaiQRButton.isHittable), scrollAttempts < 5 { + app.swipeUp() + scrollAttempts += 1 + } + XCTAssertTrue(sawaiQRButton.waitForExistence(timeout: 3)) + sawaiQRButton.tap() + + XCTAssertTrue( + app.staticTexts[ + "このセッションは仕向地「デンソー」で照合中です。別の仕向地のQRコードは照合できません。仕向地を変えるにはセッションを終了してください。 読み取った値は照合に使用していません。" + ].waitForExistence(timeout: 3) + ) + XCTAssertEqual(scannerTitle.label, "QRコードを読み取る") + XCTAssertEqual(app.staticTexts["sessionMatchCount"].label, "1件照合済み") + } + func testSettingsDiscoversAndConnectsMockScanner() { let app = launchApp(["-resetHistory", "-resetAutoAdvance", "-resetBluetoothScanner"])