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 @@ -30,7 +30,7 @@ class ItemRepository @Inject constructor(
}

suspend fun getItemByBarcode(barcode: String): Item = withContext(dispatcher) {
apiService.getItem(barcode)
getCachedItems().firstOrNull { it.barcode == barcode } ?: apiService.getItem(barcode)
}

suspend fun refreshItems(): List<Item> = withContext(dispatcher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ class MainActivity : ComponentActivity() {

@SuppressLint("RestrictedApi")
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val input = decoder.onKey(event.action, event.keyCode)
if (input != null) {
barcodeEventBus.emit(input)
val result = decoder.onKey(event.action, event.keyCode, event.unicodeChar, event.eventTime)
result.input?.let { barcodeEventBus.emit(it) }
if (result.consumed) {
return true
}
return super.dispatchKeyEvent(event)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,99 @@ package info.nukoneko.cuc.android.kidspos.ui.barcode

import info.nukoneko.cuc.android.kidspos.util.BarcodeKind

data class BarcodeKeyResult(
val consumed: Boolean = false,
val input: BarcodeInput? = null
)

class BarcodeKeyEventDecoder {
private var readingValue: String = ""
private val reading = StringBuilder()
private val consumedKeys = mutableSetOf<Int>()
private var lastKeyTime = NO_KEY_TIME

fun onKey(action: Int, keyCode: Int): BarcodeInput? {
fun onKey(
action: Int,
keyCode: Int,
unicodeChar: Int,
eventTimeMillis: Long
): BarcodeKeyResult {
if (action == ACTION_UP) {
return BarcodeKeyResult(consumed = consumedKeys.remove(keyCode))
}
if (action != ACTION_DOWN) {
return BarcodeKeyResult()
}
if (keyCode in MODIFIER_KEY_CODES) {
return BarcodeKeyResult()
}
if (keyCode in ENTER_KEY_CODES) {
return onEnter(keyCode)
}
return onCharacter(keyCode, unicodeChar, eventTimeMillis)
}

private fun onEnter(keyCode: Int): BarcodeKeyResult {
val input = parse(reading.toString())
reading.setLength(0)
lastKeyTime = NO_KEY_TIME
if (input == null) {
return BarcodeKeyResult()
}
consumedKeys += keyCode
return BarcodeKeyResult(consumed = true, input = input)
}

private fun onCharacter(
keyCode: Int,
unicodeChar: Int,
eventTimeMillis: Long
): BarcodeKeyResult {
val char = unicodeChar.toChar()
if (unicodeChar == 0 || !isBarcodeChar(char)) {
reading.setLength(0)
lastKeyTime = NO_KEY_TIME
return BarcodeKeyResult()
}
// 人が手で打つ速さでは読み取り機とみなさず、キーを画面側へ通す
val burst = reading.isNotEmpty() && eventTimeMillis - lastKeyTime <= SCAN_GAP_MILLIS
if (reading.length >= BARCODE_LENGTH) {
reading.setLength(0)
}
reading.append(char.uppercaseChar())
lastKeyTime = eventTimeMillis
if (!burst) {
return BarcodeKeyResult()
}
consumedKeys += keyCode
return BarcodeKeyResult(consumed = true)
}

private fun parse(value: String): BarcodeInput? {
if (value.length != BARCODE_LENGTH) {
return null
}
if (keyCode == KEYCODE_ENTER) {
var result: BarcodeInput? = null
if (readingValue.length == 10) {
val prefix = readingValue.substring(2, 4)
result = BarcodeInput(readingValue, BarcodeKind.prefixOf(prefix))
}
readingValue = ""
return result
}
readingValue += (keyCode - 7).toString()
return null
val prefix = if (value.first().isDigit()) {
value.substring(NUMERIC_PREFIX_START, NUMERIC_PREFIX_START + PREFIX_LENGTH)
} else {
value.substring(ALPHA_PREFIX_START, ALPHA_PREFIX_START + PREFIX_LENGTH)
}
return BarcodeInput(value, BarcodeKind.prefixOf(prefix))
}

private fun isBarcodeChar(char: Char): Boolean =
char in '0'..'9' || char in 'A'..'Z' || char in 'a'..'z'

companion object {
const val SCAN_GAP_MILLIS = 200L

private const val NO_KEY_TIME = 0L
private const val ACTION_DOWN = 0
private const val KEYCODE_ENTER = 66
private const val ACTION_UP = 1
private const val BARCODE_LENGTH = 10
private const val PREFIX_LENGTH = 2
private const val NUMERIC_PREFIX_START = 2
private const val ALPHA_PREFIX_START = 1
private val ENTER_KEY_CODES = setOf(66, 160)
private val MODIFIER_KEY_CODES = setOf(59, 60, 57, 58, 113, 114)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,40 @@ class ItemRepositoryTest {

assertEquals("1001000001", repository.getItemByBarcode("1001000001").barcode)
}

@Test
fun getItemByBarcodeReturnsCachedItemWithoutCallingApi() = runTest {
val apiService = FakeAPIService()
val dataStore = FakePreferencesDataStore()
var apiCalls = 0
apiService.getItemHandler = { barcode ->
apiCalls++
item(9).copy(barcode = barcode)
}
apiService.fetchItemsHandler = { listOf(item(1), item(2)) }
val repository =
createItemRepository(apiService, StandardTestDispatcher(testScheduler), dataStore)
repository.refreshItems()

assertEquals(item(2), repository.getItemByBarcode("1001000002"))
assertEquals(0, apiCalls)
}

@Test
fun getItemByBarcodeFallsBackToApiWhenBarcodeIsNotCached() = runTest {
val apiService = FakeAPIService()
val dataStore = FakePreferencesDataStore()
var apiCalls = 0
apiService.getItemHandler = { barcode ->
apiCalls++
item(9).copy(barcode = barcode)
}
apiService.fetchItemsHandler = { listOf(item(1)) }
val repository =
createItemRepository(apiService, StandardTestDispatcher(testScheduler), dataStore)
repository.refreshItems()

assertEquals("A01000008A", repository.getItemByBarcode("A01000008A").barcode)
assertEquals(1, apiCalls)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,82 +2,175 @@ package info.nukoneko.cuc.android.kidspos.ui.barcode

import info.nukoneko.cuc.android.kidspos.util.BarcodeKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class BarcodeKeyEventDecoderTest {
private val decoder = BarcodeKeyEventDecoder()
private var now = 10_000L

private fun typeDigits(digits: String) {
digits.forEach { digit ->
assertNull(decoder.onKey(ACTION_DOWN, digit.digitToInt() + KEYCODE_0))
}
private fun keyCodeOf(char: Char): Int = if (char in '0'..'9') {
char - '0' + KEYCODE_0
} else {
char.uppercaseChar() - 'A' + KEYCODE_A
}

private fun press(char: Char, gap: Long = SCAN_GAP): BarcodeKeyResult {
now += gap
val keyCode = keyCodeOf(char)
val down = decoder.onKey(ACTION_DOWN, keyCode, char.code, now)
decoder.onKey(ACTION_UP, keyCode, char.code, now)
return down
}

private fun pressEnter(gap: Long = SCAN_GAP): BarcodeKeyResult {
now += gap
return decoder.onKey(ACTION_DOWN, KEYCODE_ENTER, 0, now)
}

private fun type(value: String, gap: Long = SCAN_GAP) {
value.forEach { press(it, gap) }
}

private fun scan(value: String, gap: Long = SCAN_GAP): BarcodeInput? {
type(value, gap)
return pressEnter(gap).input
}

@Test
fun numericItemBarcodeIsDecoded() {
assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), scan("1001000000"))
}

@Test
fun tenDigitsFollowedByEnterProducesItemInput() {
typeDigits("1001000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result)
fun numericStaffBarcodeIsDecodedFromThirdAndFourthDigits() {
assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), scan("1000000000"))
}

@Test
fun staffPrefixIsDecodedFromThirdAndFourthDigits() {
typeDigits("1000000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), result)
fun numericSaleBarcodeIsDecoded() {
assertEquals(BarcodeInput("1002000000", BarcodeKind.SALE), scan("1002000000"))
}

@Test
fun salePrefixIsDecoded() {
typeDigits("1002000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1002000000", BarcodeKind.SALE), result)
fun unmappedNumericPrefixIsDecodedAsUnknown() {
assertEquals(BarcodeInput("1099000000", BarcodeKind.UNKNOWN), scan("1099000000"))
}

@Test
fun unmappedPrefixIsDecodedAsUnknown() {
typeDigits("1099000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1099000000", BarcodeKind.UNKNOWN), result)
fun alphanumericItemBarcodeIsDecoded() {
assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), scan("A01000008A"))
}

@Test
fun enterWithoutTenDigitsProducesNothing() {
typeDigits("123456789")
assertNull(decoder.onKey(ACTION_DOWN, KEYCODE_ENTER))
fun alphanumericStaffBarcodeIsDecodedFromSecondAndThirdCharacters() {
assertEquals(BarcodeInput("A00000001A", BarcodeKind.STAFF), scan("A00000001A"))
}

@Test
fun alphanumericSaleBarcodeIsDecoded() {
assertEquals(BarcodeInput("A02000001A", BarcodeKind.SALE), scan("A02000001A"))
}

@Test
fun unmappedAlphanumericPrefixIsDecodedAsUnknown() {
assertEquals(BarcodeInput("A99000001A", BarcodeKind.UNKNOWN), scan("A99000001A"))
}

@Test
fun lowercaseInputIsNormalizedToUppercase() {
assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), scan("a01000008a"))
}

@Test
fun shiftKeyBetweenCharactersDoesNotBreakTheRead() {
press('A')
now += SCAN_GAP
decoder.onKey(ACTION_DOWN, KEYCODE_SHIFT_LEFT, 0, now)
decoder.onKey(ACTION_UP, KEYCODE_SHIFT_LEFT, 0, now)
type("01000008A")

assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), pressEnter().input)
}

@Test
fun enterWithoutTenCharactersProducesNothing() {
assertNull(scan("123456789"))
}

@Test
fun bufferIsClearedAfterIncompleteRead() {
typeDigits("123456789")
assertNull(decoder.onKey(ACTION_DOWN, KEYCODE_ENTER))
typeDigits("1001000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result)
assertNull(scan("123456789"))

assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), scan("1001000000"))
}

@Test
fun bufferIsClearedAfterSuccessfulRead() {
typeDigits("1001000000")
decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
typeDigits("1000000000")
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), result)
scan("1001000000")

assertEquals(BarcodeInput("1000000000", BarcodeKind.STAFF), scan("1000000000"))
}

@Test
fun unsupportedCharacterClearsTheBuffer() {
type("A0100")
now += SCAN_GAP
decoder.onKey(ACTION_DOWN, KEYCODE_MINUS, '-'.code, now)
type("0008A")

assertNull(pressEnter().input)
}

@Test
fun scannedCharactersAreConsumedSoTheScreenDoesNotSeeThem() {
assertFalse(press('A').consumed)
assertTrue(press('0').consumed)
assertTrue(press('1').consumed)
}

@Test
fun nonDownActionsAreIgnored() {
typeDigits("1001000000")
assertNull(decoder.onKey(ACTION_UP, KEYCODE_ENTER))
val result = decoder.onKey(ACTION_DOWN, KEYCODE_ENTER)
assertEquals(BarcodeInput("1001000000", BarcodeKind.ITEM), result)
fun enterIsConsumedOnlyWhenABarcodeWasRead() {
type("123456789")
assertFalse(pressEnter().consumed)

type("A01000008A")
assertTrue(pressEnter().consumed)
}

@Test
fun keyUpOfAConsumedKeyIsAlsoConsumed() {
type("A0")
now += SCAN_GAP
assertTrue(decoder.onKey(ACTION_DOWN, keyCodeOf('1'), '1'.code, now).consumed)
assertTrue(decoder.onKey(ACTION_UP, keyCodeOf('1'), '1'.code, now).consumed)
}

@Test
fun keyUpOfAnUntouchedKeyIsNotConsumed() {
assertFalse(decoder.onKey(ACTION_UP, KEYCODE_ENTER, 0, now).consumed)
}

@Test
fun slowlyTypedCharactersAreNotConsumedButAreStillRead() {
val slowGap = BarcodeKeyEventDecoder.SCAN_GAP_MILLIS + 100L
"A01000008A".forEach { char ->
assertFalse(press(char, slowGap).consumed)
}

assertEquals(BarcodeInput("A01000008A", BarcodeKind.ITEM), pressEnter(slowGap).input)
}

private companion object {
const val ACTION_DOWN = 0
const val ACTION_UP = 1
const val KEYCODE_0 = 7
const val KEYCODE_A = 29
const val KEYCODE_SHIFT_LEFT = 59
const val KEYCODE_ENTER = 66
const val KEYCODE_MINUS = 69
const val SCAN_GAP = 10L
}
}
Loading