Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading