diff --git a/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt b/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt index f407137..1f36be5 100644 --- a/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt +++ b/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt @@ -4,12 +4,14 @@ import android.app.LocaleManager import android.os.Build import android.os.LocaleList import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText @@ -32,6 +34,7 @@ import jp.rimtty.codematch.core.model.AppSettings import jp.rimtty.codematch.scanner.api.InputSource import jp.rimtty.codematch.scanner.api.ScanPayload import jp.rimtty.codematch.scanner.fake.FakeExternalScanner +import jp.rimtty.codematch.scan.ScanViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After @@ -360,6 +363,140 @@ class AppFlowInstrumentationTest { onNodeWithTag(HistoryTestTags.SESSION_ROW).assertIsDisplayed() } + /** + * The Denso destination end to end: a JAMA kanban locks the session, the + * 6-4 tag matches, a second kanban of the same part is the second box, the + * same kanban is a duplicate whatever tag follows it, and a Sawai slip is + * refused with a message naming the locked destination. + */ + @Test + fun fakeScannerDensoFlowCountsBoxesPerPartNumberAndLocksDestination() { + connectFakeScannerThroughSettings() + openDestination(R.string.destination_scan) + onNodeWithTag("scan_start_session").performClick() + waitForTag("scan_waiting_card") + + // Box 1: kanban serial 0140 of part 860150-7722. + emitBluetooth( + ScanPayload.qr( + value = densoFirstBoxQrPayload, + source = InputSource.BLUETOOTH, + timestampMillis = 1_000L, + ), + ) + emitBluetooth( + ScanPayload.code128( + value = densoFirstBoxBarcodePayload, + source = InputSource.BLUETOOTH, + timestampMillis = 2_000L, + ), + ) + waitForTag("scan_result_card") + onNodeWithText("一致").assertIsDisplayed() + // The Denso part number is printed 6-4, never as a Sawai 4-2-4. + onNodeWithTag("scan_result_qr_part.value") + .performScrollTo() + .assertTextEquals("860150-7722") + // Boxes are counted per part number, so the Molten delivery summary + // must not appear. + onAllNodesWithTag("scan_result_molten_box_summary").assertCountEquals(0) + onNodeWithTag("scan_session_destination") + .performScrollTo() + .assertTextEquals("仕向地:デンソー") + assertSessionCount(1) + awaitActiveEntryCount(1) + + // Box 2: a different kanban serial (0141) of the same part. + onNodeWithTag("scan_manual_next").performClick() + waitForText("QRコード読み取り") + emitBluetooth( + ScanPayload.qr( + value = densoSecondBoxQrPayload, + source = InputSource.BLUETOOTH, + timestampMillis = 3_000L, + ), + ) + emitBluetooth( + ScanPayload.code128( + value = densoSecondBoxBarcodePayload, + source = InputSource.BLUETOOTH, + timestampMillis = 4_000L, + ), + ) + waitForTag("scan_result_card") + onNodeWithText("一致").assertIsDisplayed() + assertSessionCount(2) + awaitActiveEntryCount(2) + + // The kanban serial identifies the box, so re-reading kanban 0141 is + // the same physical box even though a new label follows it. + onNodeWithTag("scan_manual_next").performClick() + waitForText("QRコード読み取り") + emitBluetooth( + ScanPayload.qr( + value = densoSecondBoxQrPayload, + source = InputSource.BLUETOOTH, + timestampMillis = 5_000L, + ), + ) + emitBluetooth( + ScanPayload.code128( + value = densoFirstBoxBarcodePayload, + source = InputSource.BLUETOOTH, + timestampMillis = 6_000L, + ), + ) + waitForTag("scan_result_card") + onNodeWithText("すでに照合済みです").assertIsDisplayed() + assertSessionCount(2) + awaitActiveEntryCount(2) + + // A Sawai slip cannot join a Denso session; the step never advances. + onNodeWithTag("scan_manual_next").performClick() + waitForText("QRコード読み取り") + emitBluetooth( + ScanPayload.qr( + value = qrPayload, + source = InputSource.BLUETOOTH, + timestampMillis = 7_000L, + ), + ) + composeRule.onNodeWithText( + "このセッションは仕向地「デンソー」で照合中です", + substring = true, + ).performScrollTo().assertIsDisplayed() + // Scrolling to the message above can push the waiting card out of the + // compact CI emulator viewport; bring the title back before asserting. + onNodeWithText("QRコード読み取り").performScrollTo().assertIsDisplayed() + assertSessionCount(2) + assertActiveEntries( + expectedCodes = listOf( + "860150-7722", + "860150-7722", + ), + ) + + onNodeWithTag("scan_end_session").performClick() + onNodeWithText(composeRule.activity.getString(R.string.end_session_confirm)) + .performClick() + waitForTag("scan_start_session") + awaitHistoryEntryCount(expectedSessions = 1, expectedEntries = 2) + openDestination(R.string.destination_history) + waitForTag(HistoryTestTags.SESSION_ROW) + composeRule.onNodeWithTag(HistoryTestTags.SESSION_DESTINATION, useUnmergedTree = true) + .assertTextEquals("デンソー") + onNodeWithTag(HistoryTestTags.SESSION_ROW).performClick() + waitForTag(HistoryTestTags.SESSION_DETAIL) + onNodeWithTag(HistoryTestTags.SESSION_DETAIL) + .performScrollToNode(hasTestTag(HistoryTestTags.GROUP_ROW)) + onNodeWithTag(HistoryTestTags.GROUP_ROW).performClick() + waitForTag(HistoryTestTags.GROUP_DETAIL) + // Both kanbans carry one part number, so the group lists two boxes. + onNodeWithTag(HistoryTestTags.GROUP_DETAIL) + .performScrollToNode(hasText("2箱目")) + onNodeWithText("2箱目").assertIsDisplayed() + } + /** * The Molten destination end to end: the first accepted QR locks the * session, boxes are counted per delivery number with a running quantity, @@ -953,5 +1090,15 @@ class AppFlowInstrumentationTest { const val moltenFirstBoxBarcodePayload = "PAF1-15-422@0NKD3C" const val moltenSecondBoxBarcodePayload = "PAF1-15-422@0NLL3C" const val moltenSecondDeliveryBarcodePayload = "D10E-50-N10B@0UBL00" + + // Destination Denso. The first pair is the ViewModel's documented demo + // pair (kanban serial 0140); 0141 is the next box of the same part. + // The runs of spaces are blank item values, so these literals must + // never be reformatted. + const val densoFirstBoxQrPayload = ScanViewModel.SAMPLE_DENSO_QR_PAYLOAD + const val densoFirstBoxBarcodePayload = ScanViewModel.SAMPLE_DENSO_BARCODE_PAYLOAD + const val densoSecondBoxQrPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + const val densoSecondBoxBarcodePayload = "860150-7722@1DZB0O" } } diff --git a/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt b/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt index e9c11f0..b714882 100644 --- a/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt +++ b/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt @@ -157,6 +157,90 @@ class ScanViewModelCheckpointInstrumentationTest { } } + @Test + fun densoDestinationLockSurvivesIsolatedDatabaseReopen() = runBlocking { + val fixture = IsolatedScanFixture.open() + var firstOwner: TestViewModelOwner? = null + var secondOwner: TestViewModelOwner? = null + try { + val first = fixture.createViewModel() + firstOwner = first.first + val firstViewModel = first.second + + firstViewModel.onAction(jp.rimtty.codematch.feature.scan.ScanUiAction.StartSession) + awaitState(firstViewModel, "session-start") { state -> + state.sessionActive && state.phase == ScanPhase.WAITING_QR + } + val sessionId = requireNotNull(fixture.history.activeSession.first()?.id) + + // Destination Denso; the runs of spaces are blank item values. + val densoQrPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + assertEquals(221, densoQrPayload.length) + firstViewModel.onAction( + jp.rimtty.codematch.feature.scan.ScanUiAction.ScanReceived( + jp.rimtty.codematch.scanner.api.ScanPayload.qr( + value = densoQrPayload, + source = InputSource.CAMERA, + timestampMillis = 1_000L, + ), + ), + ) + val afterQr = awaitState(firstViewModel, "denso-qr-transition") { state -> + state.phase == ScanPhase.WAITING_CODE_128 && + state.qrPayload == densoQrPayload + } + assertEquals(Destination.DENSO, afterQr.destination) + + val persisted = awaitCheckpoint(fixture.history, sessionId, densoQrPayload) + assertEquals(Destination.DENSO, persisted?.destination) + + firstOwner.viewModelStore.clear() + firstOwner = null + fixture.reopenDatabase() + + val second = fixture.createViewModel() + secondOwner = second.first + val secondViewModel = second.second + val restored = awaitState(secondViewModel, "denso-lock-restoration") { state -> + state.sessionActive && state.phase == ScanPhase.WAITING_CODE_128 + } + assertEquals(Destination.DENSO, restored.destination) + + // A reread drops the accepted QR but must not drop the lock. + secondViewModel.onAction(jp.rimtty.codematch.feature.scan.ScanUiAction.RereadQr) + val afterReread = awaitState(secondViewModel, "reread") { state -> + state.phase == ScanPhase.WAITING_QR + } + assertEquals(Destination.DENSO, afterReread.destination) + + secondViewModel.onAction( + jp.rimtty.codematch.feature.scan.ScanUiAction.ScanReceived( + jp.rimtty.codematch.scanner.api.ScanPayload.qr( + value = + "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*", + source = InputSource.CAMERA, + timestampMillis = 5_000L, + ), + ), + ) + val rejected = awaitState(secondViewModel, "wrong-destination") { state -> + state.lastInvalidReason == InvalidScanReason.WRONG_DESTINATION + } + assertEquals(ScanPhase.WAITING_QR, rejected.phase) + assertEquals(Destination.DENSO, rejected.destination) + + assertEquals( + Destination.DENSO, + fixture.history.activeSession.first { it?.id == sessionId }?.destination, + ) + } finally { + secondOwner?.viewModelStore?.clear() + firstOwner?.viewModelStore?.clear() + fixture.close() + } + } + @Test fun moltenDestinationLockSurvivesIsolatedDatabaseReopen() = runBlocking { val fixture = IsolatedScanFixture.open() diff --git a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt index ed50652..084b250 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt @@ -892,5 +892,16 @@ class ScanViewModel @Inject constructor( "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" private const val SAMPLE_BARCODE_PAYLOAD = "BCJH-52-81GG@1N5X0C" private const val SAMPLE_MISMATCH_BARCODE_PAYLOAD = "BCJH-55-81GG@1KVV0C" + + /** + * The Denso demo pair (kanban serial 0140 of part 860150-7722). The + * demo action deliberately keeps using the Sawai pair above; these + * document the third destination's payload shape and drive the + * instrumentation flow. The runs of spaces are blank item values, so + * the literal must never be trimmed or reflowed. + */ + internal const val SAMPLE_DENSO_QR_PAYLOAD = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + internal const val SAMPLE_DENSO_BARCODE_PAYLOAD = "860150-7722@1DZ50O" } } diff --git a/android/feature/scan/src/androidTest/kotlin/jp/rimtty/codematch/feature/scan/ScanScreenTest.kt b/android/feature/scan/src/androidTest/kotlin/jp/rimtty/codematch/feature/scan/ScanScreenTest.kt index 7ef7234..6d1559d 100644 --- a/android/feature/scan/src/androidTest/kotlin/jp/rimtty/codematch/feature/scan/ScanScreenTest.kt +++ b/android/feature/scan/src/androidTest/kotlin/jp/rimtty/codematch/feature/scan/ScanScreenTest.kt @@ -48,6 +48,12 @@ class ScanScreenTest { "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private val moltenBarcodePayload = "PAF1-15-422@0NKD3C" + // Destination Denso: kanban 0140 of part 860150-7722, whose tag prints the + // part number as 6-4. The QR's runs of spaces are blank item values. + private val densoQrPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoBarcodePayload = "860150-7722@1DZ50O" + @Test fun inputPickerRemainsVisibleDuringCameraSwitchAndScannerRestore() { val state = mutableStateOf(ScanUiState( @@ -277,6 +283,48 @@ class ScanScreenTest { ) } + @Test + fun densoMatchResultShowsDestinationBadgeAndPlainCard() { + val session = ScanSessionState( + scan = ScanState.Result( + qrPayload = densoQrPayload, + barcodePayload = densoBarcodePayload, + result = MatchResult.MATCH, + matchedCount = 1, + ), + destination = Destination.DENSO, + recordedBoxes = listOfNotNull( + RecordedBox.fromPayloads(densoQrPayload, densoBarcodePayload), + ), + ) + composeRule.setContent { + ScanScreen( + ScanUiState.fromSession(session, sessionActive = true), + onAction = {}, + ) + } + + val context = InstrumentationRegistry.getInstrumentation().targetContext + // The card must print the Denso 6-4 form, not the Sawai 4-2-4 one. + composeRule.onNodeWithTag("scan_result_qr_part.value") + .performScrollTo() + .assertTextEquals("860150-7722") + composeRule.onNodeWithTag("scan_result_barcode_part.value") + .performScrollTo() + .assertTextEquals("860150-7722") + // Denso counts boxes per part number, so the Molten summary row that + // reports a delivery number must not appear. + composeRule.onAllNodesWithTag("scan_result_molten_box_summary").assertCountEquals(0) + composeRule.onNodeWithTag("scan_session_destination") + .performScrollTo() + .assertTextEquals( + context.getString( + R.string.scan_session_destination_format, + context.getString(R.string.scan_destination_denso), + ), + ) + } + @Test fun sawaiMatchResultKeepsThePlainTwoRowCard() { val session = ScanSessionState( diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt index 3de0243..940caf1 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanReducer.kt @@ -1,6 +1,7 @@ package jp.rimtty.codematch.feature.scan import jp.rimtty.codematch.core.matching.CodeMatcher +import jp.rimtty.codematch.core.matching.DensoKanbanQrRecord import jp.rimtty.codematch.core.matching.KanbanQrRecord import jp.rimtty.codematch.core.matching.MoltenQrRecord import jp.rimtty.codematch.core.matching.TagBarcodeRecord @@ -173,8 +174,9 @@ class ScanReducer( barcodePayload: String, ): ScanReduction { val comparison = compare(qrPayload, barcodePayload) - // The box key is destination aware: a Sawai slip identifies its own - // box, a Molten slip needs the tag's management code as well. + // The box key is destination aware: a Sawai slip and a Denso kanban + // each identify their own box (a card number and a kanban serial), a + // Molten slip needs the tag's management code as well. val identity = CodeMatcher.boxIdentity(qrPayload, barcodePayload) val result = if (comparison == MatchResult.MATCH && identity != null && @@ -215,6 +217,9 @@ class ScanReducer( val effects = buildList { add(ScanEffect.ScanAccepted) if (result == MatchResult.MATCH) { + // Detection already ran when the QR was accepted, so the + // fallback is unreachable in practice and only keeps the + // effect's destination non-null. val destination = current.destination ?: CodeMatcher.detectDestination(qrPayload) ?: Destination.SAWAI @@ -230,8 +235,9 @@ class ScanReducer( code = code, matchNumber = matchNumber, destination = destination, - // Molten counts boxes per delivery number; Sawai keeps - // counting them per part number, as the history does. + // Molten counts boxes per delivery number; Sawai and + // Denso keep counting them per part number, as the + // history does. boxNumber = summary?.boxNumber ?: next.recordedBoxes.count { it.code == code }, deliveryNumber = summary?.deliveryNumber, @@ -420,18 +426,30 @@ class ScanReducer( val length = observedQrLength(value, lockedDestination) val expected = lockedDestination?.let(CodeMatcher::expectedQrLength) when { - // A payload that parses as either destination's record is - // accepted here; the caller decides whether the session's - // lock allows that destination. + // A payload that parses as any of the three destinations' + // records is accepted here; the caller decides whether the + // session's lock allows that destination. CodeMatcher.detectDestination(value) != null -> null + // A Denso kanban declares its own item layout, so its + // payload length varies and there is no expected length a + // bad read could be measured against. + lockedDestination == Destination.DENSO -> + InvalidScanReason.INVALID_PAYLOAD // A locked session knows exactly how long its record is. expected != null && length < expected -> InvalidScanReason.INCOMPLETE_QR_PAYLOAD expected != null && length > expected -> InvalidScanReason.OVERLONG_QR_PAYLOAD expected != null -> InvalidScanReason.INVALID_PAYLOAD - // Without a lock both records are still possible, so only a - // length outside 57-66 is certainly truncated or padded. + // A JAMA payload is a Denso kanban that failed to parse, so + // the fixed Sawai/Molten lengths below say nothing about it. + value.startsWith( + DensoKanbanQrRecord.FORMAT_PREFIX, + ignoreCase = true, + ) -> InvalidScanReason.INVALID_PAYLOAD + // Without a lock both fixed-length records are still + // possible, so only a length outside 57-66 is certainly + // truncated or padded. length < MoltenQrRecord.MINIMUM_SCAN_PAYLOAD_LENGTH -> InvalidScanReason.INCOMPLETE_QR_PAYLOAD length > KanbanQrRecord.REQUIRED_SCAN_PAYLOAD_LENGTH -> @@ -447,14 +465,12 @@ class ScanReducer( } // A Code 128 symbol likewise only proves the symbology. Camera and // Bluetooth input must both carry the product-tag business format - // (a 4-2-4 part number for Sawai, 4-2-3 or 4-2-4 for Molten, - // followed by @management code) before comparison runs. + // (a 4-2-4 part number for Sawai, 4-2-3 or 4-2-4 for Molten, 6-4 + // for Denso, followed by @management code) before comparison runs. + // Before the lock any of the three is accepted; a QR is always read + // first, so in practice the destination is known here. payload.format == ScanFormat.CODE_128 -> - if (TagBarcodeRecord.isValidScanPayload( - value, - lockedDestination ?: Destination.SAWAI, - ) - ) { + if (TagBarcodeRecord.isValidScanPayload(value, lockedDestination)) { null } else { InvalidScanReason.INVALID_PAYLOAD @@ -467,18 +483,22 @@ class ScanReducer( /** * Length reported for an invalid QR. * - * A Molten record is space padded, so its surrounding spaces are data and - * must be counted; every other case keeps the trimmed length the Sawai - * messages have always shown. + * A Molten record is space padded and a Denso kanban carries blank item + * values, so their surrounding spaces are data and must be counted; every + * other case keeps the trimmed length the Sawai messages have always shown. */ private fun observedQrLength(value: String, lockedDestination: Destination?): Int = - if (lockedDestination == Destination.MOLTEN) value.length else value.trim().length + when (lockedDestination) { + Destination.MOLTEN, Destination.DENSO -> value.length + Destination.SAWAI, null -> value.trim().length + } private fun recordedCode(qrPayload: String, barcodePayload: String): String { val part = CodeMatcher.partNumberFromBarcode(barcodePayload) ?: CodeMatcher.partNumberFromQr(qrPayload) ?: qrPayload - return CodeMatcher.formatPartNumber(part) + // The QR decides the printed form: only a Denso number is 6-4. + return CodeMatcher.formatPartNumber(part, CodeMatcher.detectDestination(qrPayload)) } companion object { diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt index af71623..f7c0b6b 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanScreen.kt @@ -905,11 +905,14 @@ private fun ScanResultCard( val isMatch = result.result == MatchResult.MATCH val isDuplicate = result.result == MatchResult.DUPLICATE val countdownSeconds = state.countdownSeconds + // The locked destination decides the printed form: a Denso part number is + // 6-4, every other destination keeps 4-2-4 / 4-2-3. + val destination = state.destination val qrPart = CodeMatcher.partNumberFromQr(result.qrPayload) - ?.let(CodeMatcher::formatPartNumber) + ?.let { CodeMatcher.formatPartNumber(it, destination) } ?: result.qrPayload val barcodePart = CodeMatcher.partNumberFromBarcode(result.barcodePayload) - ?.let(CodeMatcher::formatPartNumber) + ?.let { CodeMatcher.formatPartNumber(it, destination) } ?: result.barcodePayload Card( modifier = Modifier diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt index abb90df..e0fa1ad 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt @@ -175,15 +175,12 @@ class ScanSessionCoordinator( // stabilization: the reducer rejects it on the first frame and it // never occupies the two-observation candidate slot (#78). The format // depends on the locked destination, so a 4-2-3 tag reaches the - // stabilizer only in a Molten session. + // stabilizer only in a Molten session and a 6-4 tag only in a Denso one. val payloadToDispatch = if ( payload.source == InputSource.CAMERA && payload.format == ScanFormat.CODE_128 && state.phase == ScanPhase.WAITING_CODE_128 && - TagBarcodeRecord.isValidScanPayload( - payload.value, - state.destination ?: Destination.SAWAI, - ) + TagBarcodeRecord.isValidScanPayload(payload.value, state.destination) ) { when (val stabilization = cameraStabilizer.submit(payload.value, timestamp)) { is ScanStabilizationResult.Accepted -> payload.copy(value = stabilization.value) diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanState.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanState.kt index f8cc8c3..aedc1fb 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanState.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanState.kt @@ -91,11 +91,11 @@ typealias ResultState = ScanState.Result /** * One box already recorded as a match in the active session. * - * [identity] is the destination-aware box key: the Sawai slip QR identifies its - * own box, while a Molten slip repeats for every box of the part and needs the - * product tag as well. The Molten-only fields carry the slip's delivery number - * and pack quantity so the box number and the cumulative quantity can be - * derived without re-parsing every stored payload. + * [identity] is the destination-aware box key: a Sawai slip QR and a Denso + * kanban QR each identify their own box, while a Molten slip repeats for every + * box of the part and needs the product tag as well. The Molten-only fields + * carry the slip's delivery number and pack quantity so the box number and the + * cumulative quantity can be derived without re-parsing every stored payload. */ data class RecordedBox( val identity: String, @@ -126,18 +126,27 @@ data class RecordedBox( return RecordedBox( identity = identity, code = code?.trim()?.takeIf { it.isNotEmpty() } - ?: formattedPartNumber(qrPayload, barcodePayload), + ?: formattedPartNumber(qrPayload, barcodePayload, destination), destination = destination, deliveryNumber = molten?.deliveryNumber, packQuantity = molten?.packQuantity, ) } - private fun formattedPartNumber(qrPayload: String, barcodePayload: String?): String { + /** + * The part number printed the way the destination's product tag does: + * 6-4 for Denso, 4-2-4 or 4-2-3 otherwise. Formatting a Denso number + * without its destination would silently store `8601-50-7722`. + */ + private fun formattedPartNumber( + qrPayload: String, + barcodePayload: String?, + destination: Destination?, + ): String { val part = barcodePayload?.let(CodeMatcher::partNumberFromBarcode) ?: CodeMatcher.partNumberFromQr(qrPayload) ?: qrPayload - return CodeMatcher.formatPartNumber(part) + return CodeMatcher.formatPartNumber(part, destination) } } } @@ -191,8 +200,8 @@ data class ScanSessionState( /** * Boxes and cumulative pack quantity recorded so far for one Molten - * delivery number. Sawai boxes are counted per part number instead and - * never contribute here. + * delivery number. Sawai and Denso boxes are counted per part number + * instead and never contribute here. */ fun moltenSummary(deliveryNumber: String): MoltenBoxSummary { val boxes = recordedBoxes.filter { @@ -209,9 +218,13 @@ data class ScanSessionState( * The summary for the box shown on a Molten match result, or null for any * other state. [recordedBoxes] already contains the box just recorded, so * the numbers describe the visible result rather than the previous one. + * + * The destination lock gates the parse: a Sawai or Denso payload must never + * be probed for a delivery number it does not have. */ val moltenResultSummary: MoltenBoxSummary? get() { + if (destination != Destination.MOLTEN) return null val current = scan as? ScanState.Result ?: return null if (current.result != MatchResult.MATCH) return null val record = MoltenQrRecord.parse(current.qrPayload) ?: return null @@ -297,7 +310,7 @@ sealed interface ScanEffect { /** * Box number inside the session: boxes of the same delivery number for * [Destination.MOLTEN], boxes of the same part number for - * [Destination.SAWAI]. + * [Destination.SAWAI] and [Destination.DENSO]. */ val boxNumber: Int, /** Molten only: the slip's delivery number. */ diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanCheckpointMappingTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanCheckpointMappingTest.kt index 90c8fc5..1f7b0f7 100644 --- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanCheckpointMappingTest.kt +++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanCheckpointMappingTest.kt @@ -97,6 +97,22 @@ class ScanCheckpointMappingTest { assertNull(waitingQr.toScanSessionState(false, stateDelay())?.destination) } + @Test + fun checkpointWithoutDestinationDerivesDensoFromAcceptedQr() { + val checkpoint = ScanSessionCheckpoint( + sessionId = "session", + phase = ScanCheckpointPhase.WAITING_CODE_128, + qrPayload = densoQr, + matchedCount = 0, + ) + + assertNull(checkpoint.destination) + assertEquals( + Destination.DENSO, + checkpoint.toScanSessionState(false, stateDelay())?.destination, + ) + } + @Test fun idleStateDoesNotCreateACheckpoint() { assertNull(ScanSessionState().toScanSessionCheckpoint("session")) @@ -108,4 +124,9 @@ class ScanCheckpointMappingTest { private val moltenQr = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private val moltenTag = "PAF1-15-422@0NKD3C" + + // Destination Denso: the real kanban of box 0140. The runs of spaces are + // blank item values, so the literal must never be trimmed. + private val densoQr = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" } diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt index 6dea016..c37a2a5 100644 --- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt +++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanReducerTest.kt @@ -36,11 +36,27 @@ class ScanReducerTest { private val moltenTag2SecondBox = "PAF1-15-422@0NLL3C" private val moltenTag2OtherPart = "PAF1-15-423@0N5L3C" + // Destination Denso: the real 221-character kanbans of boxes 0140 and 0141 + // of part 860150-7722 and of box 0538 of part 860150-7791. The runs of + // spaces are blank item values, so these literals must never be trimmed. + private val densoQrBox1 = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoQrBox2 = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0141SWS 20260908S0010000720000009924543330454333M6" + private val densoQrOtherPart = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507791000000192D860C01008D86045M 0538SWS 20260908S0010007680000009924543420454342R6" + private val densoTagBox1 = "860150-7722@1DZ50O" + private val densoTagBox2 = "860150-7722@1DZB0O" + private val densoTagOtherPart = "860150-7791@01335C" + @Before - fun moltenFixturesKeepTheirPadding() { + fun moltenAndDensoFixturesKeepTheirPadding() { assertEquals(61, moltenQr1.length) assertEquals(57, moltenQr1Short.length) assertEquals(61, moltenQr2.length) + assertEquals(221, densoQrBox1.length) + assertEquals(221, densoQrBox2.length) + assertEquals(221, densoQrOtherPart.length) } @Test @@ -822,6 +838,291 @@ class ScanReducerTest { assertTrue(ended.state.recordedBoxes.isEmpty()) } + @Test + fun densoQrThenSixFourTagMatchesAndCountsPerPartNumber() { + val reducer = ScanReducer() + var state = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + assertEquals(ScanPhase.WAITING_CODE_128, state.phase) + assertEquals(Destination.DENSO, state.destination) + + val result = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ) + + assertEquals(MatchResult.MATCH, result.state.result) + assertEquals(1, result.state.matchedCount) + val record = result.effects.filterIsInstance().single() + // A Denso tag prints a 6-4 part number, never the Sawai 4-2-4 form. + assertEquals("860150-7722", record.code) + assertEquals(Destination.DENSO, record.destination) + assertEquals(1, record.boxNumber) + // Denso counts boxes per part number, so the Molten-only fields stay + // empty and no delivery summary is shown. + assertNull(record.deliveryNumber) + assertNull(record.cumulativeQuantity) + assertNull(result.state.moltenResultSummary) + } + + @Test + fun densoSecondKanbanSamePartIsSecondBox() { + val reducer = ScanReducer() + var state = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + state = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ).state + state = reducer.reduce(state, ScanEvent.ManualNext).state + + // A different kanban serial (0141) is another box of the same part. + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox2))).state + val second = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox2)), + ) + + assertEquals(MatchResult.MATCH, second.state.result) + assertEquals(2, second.state.matchedCount) + val record = second.effects.filterIsInstance().single() + assertEquals("860150-7722", record.code) + assertEquals(2, record.boxNumber) + assertNull(record.deliveryNumber) + assertNull(second.state.moltenResultSummary) + } + + @Test + fun densoSameKanbanIsDuplicateEvenWithAnotherTag() { + val reducer = ScanReducer() + var state = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + state = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ).state + state = reducer.reduce(state, ScanEvent.ManualNext).state + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + + // The kanban serial identifies the box, so the same kanban read with + // another label of the same part is still that one box. + val duplicate = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox2)), + ) + + assertEquals(MatchResult.DUPLICATE, duplicate.state.result) + assertEquals(1, duplicate.state.matchedCount) + assertTrue(duplicate.effects.none { it is ScanEffect.RecordMatch }) + assertEquals(1, duplicate.state.recordedBoxes.size) + } + + @Test + fun densoSessionRejectsSawaiAndMoltenQrWithWrongDestination() { + val reducer = ScanReducer() + var denso = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + denso = reducer.reduce(denso, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + denso = reducer.reduce(denso, ScanEvent.RereadQr).state + assertEquals(Destination.DENSO, denso.destination) + + for (foreign in listOf(qrPayload, moltenQr1)) { + val rejected = reducer.reduce( + denso, + ScanEvent.PayloadReceived(ScanPayload.qr(foreign)), + ) + assertEquals(denso, rejected.state) + assertEquals( + ScanEffect.InvalidScan( + ScanFormat.QR, + InvalidScanReason.WRONG_DESTINATION, + foreign.length, + ), + rejected.effects.single(), + ) + } + + // The reverse direction: a Denso kanban never joins a Sawai session. + var sawai = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + sawai = reducer.reduce(sawai, ScanEvent.PayloadReceived(ScanPayload.qr(qrPayload))).state + sawai = reducer.reduce(sawai, ScanEvent.RereadQr).state + val densoIntoSawai = reducer.reduce( + sawai, + ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1)), + ) + assertEquals(sawai, densoIntoSawai.state) + assertEquals( + InvalidScanReason.WRONG_DESTINATION, + (densoIntoSawai.effects.single() as ScanEffect.InvalidScan).reason, + ) + } + + @Test + fun sawaiAndMoltenSessionsRejectSixFourTag() { + val reducer = ScanReducer() + var sawai = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + sawai = reducer.reduce(sawai, ScanEvent.PayloadReceived(ScanPayload.qr(qrPayload))).state + val intoSawai = reducer.reduce( + sawai, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ) + assertEquals(sawai, intoSawai.state) + assertEquals( + ScanEffect.InvalidScan( + ScanFormat.CODE_128, + InvalidScanReason.INVALID_PAYLOAD, + densoTagBox1.length, + ), + intoSawai.effects.single(), + ) + + var molten = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + molten = reducer.reduce(molten, ScanEvent.PayloadReceived(ScanPayload.qr(moltenQr1))).state + val intoMolten = reducer.reduce( + molten, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ) + assertEquals(molten, intoMolten.state) + assertEquals( + ScanEffect.InvalidScan( + ScanFormat.CODE_128, + InvalidScanReason.INVALID_PAYLOAD, + densoTagBox1.length, + ), + intoMolten.effects.single(), + ) + + // A Denso session is the only one that accepts the 6-4 tag. + var denso = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + denso = reducer.reduce(denso, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + val accepted = reducer.reduce( + denso, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ) + assertEquals(MatchResult.MATCH, accepted.state.result) + } + + @Test + fun densoLockedInvalidQrIsInvalidWithoutLengthHint() { + val reducer = ScanReducer() + var state = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + state = reducer.reduce(state, ScanEvent.RereadQr).state + assertEquals(Destination.DENSO, state.destination) + + // A Denso kanban declares its own layout, so no length is "complete" + // or "too long": every unparseable payload is simply invalid. + for (value in listOf("X".repeat(50), "X".repeat(66), "X".repeat(240))) { + val rejected = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.qr(value)), + ) + assertEquals(state, rejected.state) + assertEquals( + "length ${value.length}", + ScanEffect.InvalidScan( + ScanFormat.QR, + InvalidScanReason.INVALID_PAYLOAD, + value.length, + ), + rejected.effects.single(), + ) + } + } + + @Test + fun unlockedJamaPrefixedInvalidQrIsInvalidNotIncomplete() { + val reducer = ScanReducer() + val waiting = reducer.reduce(ScanSessionState(), ScanEvent.StartSession).state + + // A broken JAMA record is a Denso kanban of unknown length, so the + // 57-66 guidance the two fixed-length records use must not apply. + val truncated = densoQrBox1.take(40) + val rejected = reducer.reduce( + waiting, + ScanEvent.PayloadReceived(ScanPayload.qr(truncated)), + ) + assertEquals(waiting, rejected.state) + assertEquals( + ScanEffect.InvalidScan( + ScanFormat.QR, + InvalidScanReason.INVALID_PAYLOAD, + truncated.length, + ), + rejected.effects.single(), + ) + + // 63 characters sit between the two fixed record lengths and would + // otherwise be reported as a truncated Sawai slip. The prefix is + // matched case insensitively, as the record parser is. + for (value in listOf(densoQrBox1.take(63), densoQrBox1.take(63).lowercase())) { + val between = reducer.reduce( + waiting, + ScanEvent.PayloadReceived(ScanPayload.qr(value)), + ) + assertEquals(waiting, between.state) + assertEquals( + ScanEffect.InvalidScan( + ScanFormat.QR, + InvalidScanReason.INVALID_PAYLOAD, + value.length, + ), + between.effects.single(), + ) + } + } + + @Test + fun restoredDensoBoxesSeedDuplicateAndPartCounts() { + val reducer = ScanReducer() + var state = reducer.reduce( + ScanReducer.initial( + existingMatchedCount = 1, + recordedBoxes = listOfNotNull( + RecordedBox.fromPayloads(densoQrBox1, densoTagBox1), + ), + ), + ScanEvent.StartSession, + ).state + assertEquals(Destination.DENSO, state.destination) + assertEquals("860150-7722", state.recordedBoxes.single().code) + + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox1))).state + val duplicate = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox1)), + ) + assertEquals(MatchResult.DUPLICATE, duplicate.state.result) + assertEquals(1, duplicate.state.matchedCount) + + state = reducer.reduce(duplicate.state, ScanEvent.ManualNext).state + state = reducer.reduce(state, ScanEvent.PayloadReceived(ScanPayload.qr(densoQrBox2))).state + val second = reducer.reduce( + state, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagBox2)), + ) + assertEquals(MatchResult.MATCH, second.state.result) + assertEquals(2, second.state.matchedCount) + assertEquals( + 2, + second.effects.filterIsInstance().single().boxNumber, + ) + + // Another part number starts its own box count in the same session. + var next = reducer.reduce(second.state, ScanEvent.ManualNext).state + next = reducer.reduce( + next, + ScanEvent.PayloadReceived(ScanPayload.qr(densoQrOtherPart)), + ).state + val otherPart = reducer.reduce( + next, + ScanEvent.PayloadReceived(ScanPayload.code128(densoTagOtherPart)), + ) + val record = otherPart.effects.filterIsInstance().single() + assertEquals("860150-7791", record.code) + assertEquals(1, record.boxNumber) + } + private fun matchedState( reducer: ScanReducer, autoAdvanceEnabled: Boolean = false, diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt index 9971b05..aeb02c8 100644 --- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt +++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt @@ -46,6 +46,12 @@ class ScanSessionCoordinatorTest { "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" private val moltenShortPartBarcode = "PAF1-15-422@0NKD3C" + // Destination Denso, whose product tag prints a 6-4 part number. The QR's + // runs of spaces are blank item values. + private val densoQrPayload = + "JAMA501195000001021100021041011102112071210412406127041410214201144061520440205515015160151908520045210652606523105220640102208601507722000000024D850C01008D85045M 0140SWS 20260908S0010000720000009924543330454333M6" + private val densoBarcodePayload = "860150-7722@1DZ50O" + @Test fun restartOnMatchKeepsResultAndCountThenManualNextResumesQr() { val scanner = TestScanner().apply { @@ -600,6 +606,36 @@ class ScanSessionCoordinatorTest { assertEquals(ScanPhase.WAITING_CODE_128, sawai.state.phase) } + @Test + fun cameraSixFourTagGoesThroughStabilizerOnlyInDensoSession() { + val denso = ScanSessionCoordinator(TestScanner()) + denso.startSession() + denso.submitScanPayload(ScanPayload.qr(densoQrPayload, timestampMillis = 0L)) + assertEquals(Destination.DENSO, denso.state.destination) + + // A 6-4 tag is a valid Denso product tag, so it takes the strict + // two-observation path instead of being rejected on the first frame. + assertNull( + denso.submitScanPayload( + ScanPayload.code128(densoBarcodePayload, timestampMillis = 300L), + ), + ) + val accepted = denso.submitScanPayload( + ScanPayload.code128(densoBarcodePayload, timestampMillis = 400L), + ) + assertEquals(ScanPhase.RESULT, accepted?.state?.phase) + assertEquals(MatchResult.MATCH, accepted?.state?.result) + + val sawai = ScanSessionCoordinator(TestScanner()) + sawai.startSession() + sawai.submitScanPayload(ScanPayload.qr(qrPayload, timestampMillis = 0L)) + val rejected = sawai.submitScanPayload( + ScanPayload.code128(densoBarcodePayload, timestampMillis = 300L), + ) + assertTrue(rejected?.effects?.single() is ScanEffect.InvalidScan) + assertEquals(ScanPhase.WAITING_CODE_128, sawai.state.phase) + } + private class TestScanner : ExternalScanner { private val device = ScannerDevice("test", "Test scanner") override var devices: List = listOf(device)