From c5d246fea51354bc8ad2ece8563f81eb2e0b4631 Mon Sep 17 00:00:00 2001 From: atsumi Date: Sun, 23 Aug 2026 05:33:24 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E9=9B=BB=E5=8D=93=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=82=92=E3=83=AC=E3=82=B8=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=A8=E3=81=97=E3=81=A6=E4=BD=9C=E3=82=8A=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ごうけい・おあずかり・おつり/たりない を大きな文字で常時表示し、数字を押しながら比較できるようにする - 預かりが足りないときは たりない と不足額をエラー色で、足りたときは おつり を強調色で表示する - 会計結果はおつりを主役にした専用ダイアログにし、ごうけい・おあずかりを補足に降格する - タブレットの広い画面では金額と数字パッドを左右2カラムに並べる - 文字崩れを防ぐため、実測して枠に収まる最大のタイポグラフィを選ぶ - バージョンを 1.0.10 に上げる --- app/build.gradle.kts | 4 +- .../cuc/android/kidspos/ui/main/MainScreen.kt | 417 +++++++++++++++--- app/src/main/res/values/strings.xml | 1 + .../cuc/android/kidspos/ui/ScreenshotTest.kt | 13 + .../android/kidspos/ui/main/MainScreenTest.kt | 85 ++++ 5 files changed, 451 insertions(+), 69 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21dd952..86b858b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,8 +34,8 @@ android { applicationId = "info.nukoneko.cuc.android.kidspos" minSdk = 23 targetSdk = 36 - versionCode = 10 - versionName = "1.0.9" + versionCode = 11 + versionName = "1.0.10" testApplicationId = "info.nukoneko.cuc.android.kidspos.test" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resourceConfigurations += "ja" diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt index 1bf8b89..ab57272 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt @@ -3,6 +3,7 @@ package info.nukoneko.cuc.android.kidspos.ui.main import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -12,7 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -45,13 +46,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import info.nukoneko.cuc.android.kidspos.R @@ -260,6 +269,42 @@ private fun ErrorDialog(message: String, onDismiss: () -> Unit) { ) } +internal const val CalculatorSummaryTag = "calculator_summary" +internal const val CalculatorAccountButtonTag = "calculator_account_button" +internal const val AccountResultConfirmButtonTag = "account_result_confirm_button" + +private val DialogMaxWidth = 720.dp +private val NumberPadMaxWidth = 360.dp +private val TwoPaneMinWidth = 600.dp +private val KeyGap = 4.dp +private val AmountSafetyMargin = 8.dp + +// タブレットでも文字が切れないよう、実測して収まる最大のスタイルを選ぶ +@Composable +private fun fittingStyle(samples: List, candidates: List, maxWidth: Dp): TextStyle { + val measurer = rememberTextMeasurer() + val maxWidthPx = with(LocalDensity.current) { maxWidth.coerceAtLeast(0.dp).toPx() } + return candidates.firstOrNull { style -> + samples.all { sample -> + measurer.measure(text = sample, style = style, softWrap = false, maxLines = 1) + .size.width <= maxWidthPx + } + } ?: candidates.last() +} + +@Composable +private fun amountStyleCandidates(): List { + val typography = MaterialTheme.typography + return listOf( + typography.displayMedium, + typography.displaySmall, + typography.headlineLarge, + typography.headlineMedium, + typography.headlineSmall, + typography.titleLarge + ).map { it.copy(fontWeight = FontWeight.Bold) } +} + @Composable private fun CalculatorDialog( state: CalculatorState, @@ -268,33 +313,85 @@ private fun CalculatorDialog( onOk: () -> Unit, onDismiss: () -> Unit ) { - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.medium - ) { - Column( - modifier = Modifier - .padding(16.dp) - .verticalScroll(rememberScrollState()) + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + BoxWithConstraints(modifier = Modifier.padding(16.dp)) { + val contentWidth = min(maxWidth, DialogMaxWidth) + val innerWidth = contentWidth - 48.dp + val twoPane = contentWidth >= TwoPaneMinWidth + val padWidth = min(innerWidth, NumberPadMaxWidth) + val summaryWidth = if (twoPane) innerWidth - padWidth - 24.dp else innerWidth + + Surface( + shape = MaterialTheme.shapes.medium, + modifier = Modifier.width(contentWidth) ) { - Text("${stringResource(R.string.total)}: ${stringResource(R.string.river_format, state.totalPrice)}") - Text("${stringResource(R.string.deposit)}: ${stringResource(R.string.river_format, state.deposit)}") - Spacer(modifier = Modifier.padding(8.dp)) - NumberPad( - onNumber = onNumber, - onClear = onClear, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.padding(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()) ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.back)) + if (twoPane) { + Row(modifier = Modifier.fillMaxWidth()) { + CalculatorSummary( + state = state, + maxWidth = summaryWidth, + modifier = Modifier.width(summaryWidth) + ) + Spacer(modifier = Modifier.width(24.dp)) + NumberPad( + onNumber = onNumber, + onClear = onClear, + modifier = Modifier.width(padWidth) + ) + } + } else { + CalculatorSummary( + state = state, + maxWidth = summaryWidth, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.padding(12.dp)) + NumberPad( + onNumber = onNumber, + onClear = onClear, + modifier = Modifier + .width(padWidth) + .align(Alignment.CenterHorizontally) + ) } - Button(onClick = onOk, enabled = state.accountEnabled) { - Text(stringResource(R.string.account)) + Spacer(modifier = Modifier.padding(10.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.padding(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.back), + style = MaterialTheme.typography.titleLarge, + maxLines = 1 + ) + } + Button( + onClick = onOk, + enabled = state.accountEnabled, + contentPadding = PaddingValues(horizontal = 40.dp, vertical = 16.dp), + modifier = Modifier.testTag(CalculatorAccountButtonTag) + ) { + Text( + text = stringResource(R.string.account), + style = MaterialTheme.typography.headlineSmall, + maxLines = 1 + ) + } } } } @@ -302,7 +399,76 @@ private fun CalculatorDialog( } } -private val NumberPadMaxWidth = 360.dp +@Composable +private fun CalculatorSummary(state: CalculatorState, maxWidth: Dp, modifier: Modifier = Modifier) { + val remaining = state.deposit - state.totalPrice + val totalText = stringResource(R.string.river_format, state.totalPrice) + val depositText = stringResource(R.string.river_format, state.deposit) + val remainingText = stringResource( + R.string.river_format, + if (remaining >= 0) remaining else -remaining + ) + val amountStyle = fittingStyle( + samples = listOf(totalText, depositText, remainingText), + candidates = amountStyleCandidates(), + maxWidth = maxWidth - AmountSafetyMargin + ) + val onSurface = MaterialTheme.colorScheme.onSurface + + Column(modifier = modifier.testTag(CalculatorSummaryTag)) { + AmountBlock( + label = stringResource(R.string.total), + amount = totalText, + style = amountStyle, + color = onSurface + ) + Spacer(modifier = Modifier.padding(6.dp)) + AmountBlock( + label = stringResource(R.string.deposit), + amount = depositText, + style = amountStyle, + color = onSurface + ) + Spacer(modifier = Modifier.padding(6.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.padding(6.dp)) + AmountBlock( + label = stringResource(if (remaining >= 0) R.string.change else R.string.shortage), + amount = remainingText, + style = amountStyle, + color = if (remaining >= 0) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + ) + } +} + +@Composable +private fun AmountBlock(label: String, amount: String, style: TextStyle, color: Color) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = color, + maxLines = 1 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = amount, + style = style, + color = color, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip + ) + } + } +} @Composable private fun NumberPad(onNumber: (Int) -> Unit, onClear: () -> Unit, modifier: Modifier = Modifier) { @@ -311,45 +477,74 @@ private fun NumberPad(onNumber: (Int) -> Unit, onClear: () -> Unit, modifier: Mo listOf(4, 5, 6), listOf(7, 8, 9) ) - Column(modifier = modifier.widthIn(max = NumberPadMaxWidth)) { - rows.forEach { row -> - Row(modifier = Modifier.fillMaxWidth()) { - row.forEach { number -> - NumberPadButton( - text = "$number", - onClick = { onNumber(number) }, - modifier = Modifier.weight(1f) - ) + val deleteLabel = stringResource(R.string.delete) + BoxWithConstraints(modifier = modifier) { + val typography = MaterialTheme.typography + val keyWidth = maxWidth / 3 - KeyGap * 2 + val keyStyle = fittingStyle( + samples = listOf("8", deleteLabel), + candidates = listOf( + typography.headlineLarge, + typography.headlineMedium, + typography.headlineSmall, + typography.titleLarge + ), + maxWidth = keyWidth * 0.85f + ) + Column(modifier = Modifier.fillMaxWidth()) { + rows.forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { number -> + NumberPadButton( + text = "$number", + style = keyStyle, + onClick = { onNumber(number) }, + modifier = Modifier.weight(1f) + ) + } } } - } - Row(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.weight(1f)) - NumberPadButton( - text = "0", - onClick = { onNumber(0) }, - modifier = Modifier.weight(1f) - ) - NumberPadButton( - text = stringResource(R.string.delete), - onClick = onClear, - modifier = Modifier.weight(1f) - ) + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.weight(1f)) + NumberPadButton( + text = "0", + style = keyStyle, + onClick = { onNumber(0) }, + modifier = Modifier.weight(1f) + ) + NumberPadButton( + text = deleteLabel, + style = keyStyle, + onClick = onClear, + modifier = Modifier.weight(1f) + ) + } } } } @Composable -private fun NumberPadButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { +private fun NumberPadButton( + text: String, + style: TextStyle, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { Button( onClick = onClick, shape = MaterialTheme.shapes.medium, contentPadding = PaddingValues(0.dp), modifier = modifier - .padding(4.dp) + .padding(KeyGap) .aspectRatio(1f) ) { - Text(text = text, style = MaterialTheme.typography.headlineSmall) + Text( + text = text, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip + ) } } @@ -359,25 +554,113 @@ private fun AccountResultDialog( onOk: () -> Unit, onBack: () -> Unit ) { - AlertDialog( + Dialog( onDismissRequest = onBack, - title = { Text(stringResource(R.string.account)) }, - text = { - Column { - Text("${stringResource(R.string.total)}: ${stringResource(R.string.river_format, state.totalPrice)}") - Text("${stringResource(R.string.deposit)}: ${stringResource(R.string.river_format, state.deposit)}") - Text("${stringResource(R.string.change)}: ${stringResource(R.string.river_format, state.change)}") - } - }, - confirmButton = { - Button(onClick = onOk) { Text(stringResource(R.string.account)) } - }, - dismissButton = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.go_back)) + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + BoxWithConstraints(modifier = Modifier.padding(16.dp)) { + val typography = MaterialTheme.typography + val contentWidth = min(maxWidth, DialogMaxWidth) + val changeText = stringResource(R.string.river_format, state.change) + val changeStyle = fittingStyle( + samples = listOf(changeText), + candidates = listOf( + typography.displayLarge, + typography.displayMedium, + typography.displaySmall, + typography.headlineLarge, + typography.headlineMedium, + typography.headlineSmall + ).map { it.copy(fontWeight = FontWeight.Bold) }, + maxWidth = contentWidth - 64.dp + ) + + Surface( + shape = MaterialTheme.shapes.medium, + modifier = Modifier.width(contentWidth) + ) { + Column( + modifier = Modifier + .padding(32.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.change), + style = typography.headlineMedium, + maxLines = 1 + ) + Spacer(modifier = Modifier.padding(4.dp)) + Text( + text = changeText, + style = changeStyle, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip + ) + Spacer(modifier = Modifier.padding(10.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.padding(6.dp)) + ResultSummaryRow( + label = stringResource(R.string.total), + amount = stringResource(R.string.river_format, state.totalPrice) + ) + ResultSummaryRow( + label = stringResource(R.string.deposit), + amount = stringResource(R.string.river_format, state.deposit) + ) + Spacer(modifier = Modifier.padding(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onBack, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.go_back), + style = typography.titleLarge, + maxLines = 1 + ) + } + Button( + onClick = onOk, + contentPadding = PaddingValues(horizontal = 40.dp, vertical = 16.dp), + modifier = Modifier.testTag(AccountResultConfirmButtonTag) + ) { + Text( + text = stringResource(R.string.account), + style = typography.headlineSmall, + maxLines = 1 + ) + } + } + } } } - ) + } +} + +@Composable +private fun ResultSummaryRow(label: String, amount: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.titleLarge, maxLines = 1) + Text( + text = amount, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip + ) + } } @Composable diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3d7d36..1e01b4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ もどる メイン画面に戻る おつり + たりない おあずかり ごうけい 閉じる diff --git a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt index b10598e..8d835cd 100644 --- a/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt +++ b/app/src/test/kotlin/info/nukoneko/cuc/android/kidspos/ui/ScreenshotTest.kt @@ -130,6 +130,19 @@ class ScreenshotTest { captureScreenRoboImage("screenshots/main_screen_calculator.png") } + @Test + fun mainScreenCalculatorShortage() { + setUpStoreAndStaff() + val viewModel = mainViewModel() + addItems(viewModel) + viewModel.onAccountClick() + viewModel.onCalculatorNumber(1) + viewModel.onCalculatorNumber(0) + viewModel.onCalculatorNumber(0) + composeRule.waitForIdle() + captureScreenRoboImage("screenshots/main_screen_calculator_shortage.png") + } + @Test fun mainScreenAccountResult() { setUpStoreAndStaff() diff --git a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt index 0eab302..c277d1e 100644 --- a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt +++ b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.unit.height @@ -151,6 +152,90 @@ class MainScreenTest { ) } + @Test + fun calculatorShowsShortageUntilDepositCoversTotal() { + openCalculator() + + composeRule.onNodeWithTag(CalculatorAccountButtonTag).assertIsNotEnabled() + + pressKeys("1", "0", "0") + + composeRule.onNodeWithText(context.getString(R.string.shortage)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.river_format, 100)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.river_format, 200)).assertIsDisplayed() + composeRule.onNodeWithTag(CalculatorAccountButtonTag).assertIsNotEnabled() + } + + @Test + fun calculatorShowsChangeWhenDepositIsEnough() { + openCalculator() + + pressKeys("5", "0", "0") + + composeRule.onNodeWithText(context.getString(R.string.change)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.river_format, 500)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.river_format, 200)).assertIsDisplayed() + composeRule.onNodeWithTag(CalculatorAccountButtonTag).assertIsEnabled() + } + + @Test + fun calculatorAmountsAreLargerThanTheirLabels() { + openCalculator() + + pressKeys("5", "0", "0") + + val labelHeight = heightOf(context.getString(R.string.deposit)) + val amountHeight = heightOf(context.getString(R.string.river_format, 500)) + assertTrue( + "金額がラベルより大きく表示されていない: label=$labelHeight amount=$amountHeight", + amountHeight > labelHeight + ) + } + + @Test + fun calculatorAmountsFitInsideTheDialogWidth() { + openCalculator() + + pressKeys("9", "9", "9", "9", "9", "9") + + val summaryWidth = composeRule.onNodeWithTag(CalculatorSummaryTag) + .getUnclippedBoundsInRoot().width.value + listOf(999999, 999699).forEach { amount -> + val width = boundsOf(context.getString(R.string.river_format, amount)).width.value + assertTrue("$amount の表示が枠に収まっていない: $width / $summaryWidth", width < summaryWidth) + } + } + + @Test + fun accountResultShowsChangeAsTheLargestText() { + openCalculator() + + pressKeys("5", "0", "0") + composeRule.onNodeWithTag(CalculatorAccountButtonTag).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText(context.getString(R.string.change)).assertIsDisplayed() + composeRule.onNodeWithTag(AccountResultConfirmButtonTag).assertIsDisplayed() + + val changeHeight = heightOf(context.getString(R.string.river_format, 200)) + val depositHeight = heightOf(context.getString(R.string.river_format, 500)) + assertTrue( + "おつりがほかの金額より大きく表示されていない: change=$changeHeight deposit=$depositHeight", + changeHeight > depositHeight * 1.5f + ) + } + + private fun pressKeys(vararg keys: String) { + keys.forEach { key -> + composeRule.onNodeWithText(key).performClick() + } + composeRule.waitForIdle() + } + + private fun boundsOf(text: String) = composeRule.onNodeWithText(text).getUnclippedBoundsInRoot() + + private fun heightOf(text: String) = boundsOf(text).height.value + private fun openCalculator() { apiService.getItemHandler = { barcode -> Item(id = 1, barcode = barcode, name = "テスト商品", price = 300, storeId = 1, genreId = 1) From d1d163566215a0b48960f2fed7df21e4dbcde5fe Mon Sep 17 00:00:00 2001 From: atsumi Date: Sun, 23 Aug 2026 05:49:49 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E9=9B=BB=E5=8D=93=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E3=82=BF=E3=83=96=E3=83=AC=E3=83=83?= =?UTF-8?q?=E3=83=88=E7=94=BB=E9=9D=A2=E3=82=B5=E3=82=A4=E3=82=BA=E3=81=A7?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robolectric のデフォルト端末は 320x470dp と狭く、電卓ダイアログの数字キーが 縦スクロールの外に置かれてタップが届かないため、預かり金額が加算されず 金額表示の検証がすべて失敗していた。実際の利用端末に合わせて MediumTablet の画面サイズで実行し、キーが表示されていることも明示的に確認する。 --- .../cuc/android/kidspos/ui/main/MainScreenTest.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt index c277d1e..d7ee77f 100644 --- a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt +++ b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.height import androidx.compose.ui.unit.width import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import info.nukoneko.cuc.android.kidspos.R import info.nukoneko.cuc.android.kidspos.entity.Item import info.nukoneko.cuc.android.kidspos.testutil.FakeAPIService @@ -34,9 +35,11 @@ import org.junit.rules.RuleChain import org.junit.runner.RunWith import org.robolectric.annotation.Config -// Robolectric の SDK 36 実行は JDK 21 が必要なため、CI の JDK 17 で動く SDK 35 に固定する +// Robolectric の SDK 36 実行は JDK 21 が必要なため、CI の JDK 17 で動く SDK 35 に固定する。 +// デフォルト端末は 320x470dp と狭く、電卓の数字キーが画面外に置かれてタップが届かないため、 +// 実際の利用端末であるタブレットの画面サイズで実行する @RunWith(AndroidJUnit4::class) -@Config(sdk = [35]) +@Config(sdk = [35], qualifiers = RobolectricDeviceQualifiers.MediumTablet) class MainScreenTest { private val mainDispatcherRule = MainDispatcherRule() private val composeRule = createComposeRule() @@ -227,9 +230,9 @@ class MainScreenTest { private fun pressKeys(vararg keys: String) { keys.forEach { key -> - composeRule.onNodeWithText(key).performClick() + composeRule.onNodeWithText(key).assertIsDisplayed().performClick() + composeRule.waitForIdle() } - composeRule.waitForIdle() } private fun boundsOf(text: String) = composeRule.onNodeWithText(text).getUnclippedBoundsInRoot() From a2e18b5688385a5a2720e8b90fccb0282442be00 Mon Sep 17 00:00:00 2001 From: atsumi Date: Sun, 23 Aug 2026 08:05:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E9=9B=BB=E5=8D=93=E3=83=80=E3=82=A4?= =?UTF-8?q?=E3=82=A2=E3=83=AD=E3=82=B0=E3=82=92=E5=B0=8F=E3=81=95=E3=81=84?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=AB=E5=8F=8E=E3=82=81=E3=80=81=E4=BC=9A?= =?UTF-8?q?=E8=A8=88=E4=B8=AD=E3=81=AB=E3=81=90=E3=82=8B=E3=81=90=E3=82=8B?= =?UTF-8?q?=E3=82=92=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - もどる・かいけいのフッターをスクロール領域の外に固定し、常に押せるようにする - 残りの高さからキーパッドの大きさを決め、Nexus 7 の横向きでも画面に収まるようにする - 金額の文字サイズを横幅だけでなく高さでも判定する - おつり画面のかいけい押下からレシート発行が終わるまで進捗インジケータを表示し、二重送信を防ぐ --- .../cuc/android/kidspos/ui/main/MainScreen.kt | 226 ++++++++++++------ .../android/kidspos/ui/main/MainViewModel.kt | 11 +- .../android/kidspos/ui/main/MainScreenTest.kt | 75 ++++++ .../kidspos/ui/main/MainViewModelTest.kt | 58 +++++ 4 files changed, 295 insertions(+), 75 deletions(-) diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt index ab57272..e666c0b 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells @@ -29,6 +30,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer @@ -269,15 +271,33 @@ private fun ErrorDialog(message: String, onDismiss: () -> Unit) { ) } +internal const val CalculatorDialogTag = "calculator_dialog" internal const val CalculatorSummaryTag = "calculator_summary" internal const val CalculatorAccountButtonTag = "calculator_account_button" internal const val AccountResultConfirmButtonTag = "account_result_confirm_button" +internal const val AccountResultProgressTag = "account_result_progress" private val DialogMaxWidth = 720.dp +private val DialogPadding = 24.dp private val NumberPadMaxWidth = 360.dp +private val NumberPadMinWidth = 156.dp private val TwoPaneMinWidth = 600.dp private val KeyGap = 4.dp private val AmountSafetyMargin = 8.dp +private val SummaryGapHeight = 40.dp +private val PaneGap = 24.dp +private val ProgressIndicatorSize = 24.dp +private const val NumberPadRows = 4 +private const val NumberPadColumns = 3 +private const val AmountBlockCount = 3 +private const val SinglePaneSummaryRatio = 0.45f + +// 高さが足りない端末では横幅ではなく高さがキーの大きさを決める +private fun fittingPadWidth(availableWidth: Dp, availableHeight: Dp): Dp { + val byWidth = min(availableWidth, NumberPadMaxWidth) + val byHeight = availableHeight * (NumberPadColumns.toFloat() / NumberPadRows) + return min(byWidth, byHeight).coerceAtLeast(min(NumberPadMinWidth, byWidth)) +} // タブレットでも文字が切れないよう、実測して収まる最大のスタイルを選ぶ @Composable @@ -292,6 +312,32 @@ private fun fittingStyle(samples: List, candidates: List, max } ?: candidates.last() } +// 横幅だけでなく、3段の金額とラベルが縦にも収まるスタイルを選ぶ +@Composable +private fun fittingAmountStyle( + samples: List, + candidates: List, + maxWidth: Dp, + maxHeight: Dp +): TextStyle { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + val maxWidthPx = with(density) { maxWidth.coerceAtLeast(0.dp).toPx() } + val labelsHeightPx = measurer + .measure(text = "0", style = MaterialTheme.typography.titleMedium, maxLines = 1) + .size.height * AmountBlockCount + val amountBudgetPx = with(density) { + (maxHeight - SummaryGapHeight).coerceAtLeast(0.dp).toPx() + } - labelsHeightPx + return candidates.firstOrNull { style -> + val measured = samples.map { + measurer.measure(text = it, style = style, softWrap = false, maxLines = 1) + } + measured.all { it.size.width <= maxWidthPx } && + measured.maxOf { it.size.height } * AmountBlockCount <= amountBudgetPx + } ?: candidates.last() +} + @Composable private fun amountStyleCandidates(): List { val typography = MaterialTheme.typography @@ -319,50 +365,63 @@ private fun CalculatorDialog( ) { BoxWithConstraints(modifier = Modifier.padding(16.dp)) { val contentWidth = min(maxWidth, DialogMaxWidth) - val innerWidth = contentWidth - 48.dp + val availableHeight = maxHeight + val innerWidth = contentWidth - DialogPadding * 2 val twoPane = contentWidth >= TwoPaneMinWidth - val padWidth = min(innerWidth, NumberPadMaxWidth) - val summaryWidth = if (twoPane) innerWidth - padWidth - 24.dp else innerWidth Surface( shape = MaterialTheme.shapes.medium, - modifier = Modifier.width(contentWidth) + modifier = Modifier + .width(contentWidth) + .heightIn(max = availableHeight) + .testTag(CalculatorDialogTag) ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()) - ) { - if (twoPane) { - Row(modifier = Modifier.fillMaxWidth()) { - CalculatorSummary( - state = state, - maxWidth = summaryWidth, - modifier = Modifier.width(summaryWidth) - ) - Spacer(modifier = Modifier.width(24.dp)) - NumberPad( - onNumber = onNumber, - onClear = onClear, - modifier = Modifier.width(padWidth) - ) + Column(modifier = Modifier.padding(DialogPadding)) { + // フッターを先に測らせ、その残りの高さでキーパッドの大きさを決める + BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) { + val bodyHeight = maxHeight + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + if (twoPane) { + val padWidth = fittingPadWidth(innerWidth - PaneGap, bodyHeight) + val summaryWidth = innerWidth - padWidth - PaneGap + Row(modifier = Modifier.fillMaxWidth()) { + CalculatorSummary( + state = state, + maxWidth = summaryWidth, + maxHeight = bodyHeight, + modifier = Modifier.width(summaryWidth) + ) + Spacer(modifier = Modifier.width(PaneGap)) + NumberPad( + onNumber = onNumber, + onClear = onClear, + modifier = Modifier.width(padWidth) + ) + } + } else { + val summaryHeight = bodyHeight * SinglePaneSummaryRatio + CalculatorSummary( + state = state, + maxWidth = innerWidth, + maxHeight = summaryHeight, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.padding(12.dp)) + NumberPad( + onNumber = onNumber, + onClear = onClear, + modifier = Modifier + .width( + fittingPadWidth( + innerWidth, + bodyHeight - summaryHeight - PaneGap + ) + ).align(Alignment.CenterHorizontally) + ) + } } - } else { - CalculatorSummary( - state = state, - maxWidth = summaryWidth, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.padding(12.dp)) - NumberPad( - onNumber = onNumber, - onClear = onClear, - modifier = Modifier - .width(padWidth) - .align(Alignment.CenterHorizontally) - ) } - Spacer(modifier = Modifier.padding(10.dp)) + Spacer(modifier = Modifier.padding(8.dp)) HorizontalDivider() Spacer(modifier = Modifier.padding(8.dp)) Row( @@ -400,7 +459,12 @@ private fun CalculatorDialog( } @Composable -private fun CalculatorSummary(state: CalculatorState, maxWidth: Dp, modifier: Modifier = Modifier) { +private fun CalculatorSummary( + state: CalculatorState, + maxWidth: Dp, + maxHeight: Dp, + modifier: Modifier = Modifier +) { val remaining = state.deposit - state.totalPrice val totalText = stringResource(R.string.river_format, state.totalPrice) val depositText = stringResource(R.string.river_format, state.deposit) @@ -408,10 +472,11 @@ private fun CalculatorSummary(state: CalculatorState, maxWidth: Dp, modifier: Mo R.string.river_format, if (remaining >= 0) remaining else -remaining ) - val amountStyle = fittingStyle( + val amountStyle = fittingAmountStyle( samples = listOf(totalText, depositText, remainingText), candidates = amountStyleCandidates(), - maxWidth = maxWidth - AmountSafetyMargin + maxWidth = maxWidth - AmountSafetyMargin, + maxHeight = maxHeight ) val onSurface = MaterialTheme.colorScheme.onSurface @@ -561,6 +626,7 @@ private fun AccountResultDialog( BoxWithConstraints(modifier = Modifier.padding(16.dp)) { val typography = MaterialTheme.typography val contentWidth = min(maxWidth, DialogMaxWidth) + val availableHeight = maxHeight val changeText = stringResource(R.string.river_format, state.change) val changeStyle = fittingStyle( samples = listOf(changeText), @@ -577,39 +643,43 @@ private fun AccountResultDialog( Surface( shape = MaterialTheme.shapes.medium, - modifier = Modifier.width(contentWidth) + modifier = Modifier + .width(contentWidth) + .heightIn(max = availableHeight) ) { - Column( - modifier = Modifier - .padding(32.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.change), - style = typography.headlineMedium, - maxLines = 1 - ) - Spacer(modifier = Modifier.padding(4.dp)) - Text( - text = changeText, - style = changeStyle, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Clip - ) - Spacer(modifier = Modifier.padding(10.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.padding(6.dp)) - ResultSummaryRow( - label = stringResource(R.string.total), - amount = stringResource(R.string.river_format, state.totalPrice) - ) - ResultSummaryRow( - label = stringResource(R.string.deposit), - amount = stringResource(R.string.river_format, state.deposit) - ) + Column(modifier = Modifier.padding(32.dp)) { + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.change), + style = typography.headlineMedium, + maxLines = 1 + ) + Spacer(modifier = Modifier.padding(4.dp)) + Text( + text = changeText, + style = changeStyle, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip + ) + Spacer(modifier = Modifier.padding(10.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.padding(6.dp)) + ResultSummaryRow( + label = stringResource(R.string.total), + amount = stringResource(R.string.river_format, state.totalPrice) + ) + ResultSummaryRow( + label = stringResource(R.string.deposit), + amount = stringResource(R.string.river_format, state.deposit) + ) + } Spacer(modifier = Modifier.padding(10.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -618,6 +688,7 @@ private fun AccountResultDialog( ) { TextButton( onClick = onBack, + enabled = !state.processing, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp) ) { Text( @@ -628,9 +699,20 @@ private fun AccountResultDialog( } Button( onClick = onOk, + enabled = !state.processing, contentPadding = PaddingValues(horizontal = 40.dp, vertical = 16.dp), modifier = Modifier.testTag(AccountResultConfirmButtonTag) ) { + if (state.processing) { + CircularProgressIndicator( + strokeWidth = 3.dp, + color = LocalContentColor.current, + modifier = Modifier + .size(ProgressIndicatorSize) + .testTag(AccountResultProgressTag) + ) + Spacer(modifier = Modifier.width(12.dp)) + } Text( text = stringResource(R.string.account), style = typography.headlineSmall, diff --git a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModel.kt b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModel.kt index 794f652..ad68e6f 100644 --- a/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModel.kt +++ b/app/src/main/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModel.kt @@ -54,7 +54,8 @@ data class CalculatorState( data class AccountResultState( val totalPrice: Int, - val deposit: Int + val deposit: Int, + val processing: Boolean = false ) { val change: Int get() = deposit - totalPrice } @@ -194,6 +195,7 @@ class MainViewModel @Inject constructor( fun onAccountResultBack() { val result = _uiState.value.accountResult ?: return + if (result.processing) return _uiState.update { it.copy( accountResult = null, @@ -205,6 +207,7 @@ class MainViewModel @Inject constructor( fun onAccountResultOk() { val state = _uiState.value val result = state.accountResult ?: return + if (result.processing) return if (state.mode == Mode.PRACTICE) { _uiState.update { it.copy( @@ -216,6 +219,7 @@ class MainViewModel @Inject constructor( } return } + _uiState.update { it.copy(accountResult = result.copy(processing = true)) } viewModelScope.launch { try { saleRepository.createSale( @@ -230,10 +234,11 @@ class MainViewModel @Inject constructor( Timber.e(e, "createSale failed") val message = e.localizedMessage _uiState.update { + val restored = it.copy(accountResult = result.copy(processing = false)) if (message != null) { - it.copy(errorMessage = message) + restored.copy(errorMessage = message) } else { - it.copy(errorMessageRes = R.string.network_error) + restored.copy(errorMessageRes = R.string.network_error) } } } diff --git a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt index d7ee77f..134a9a9 100644 --- a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt +++ b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainScreenTest.kt @@ -26,8 +26,11 @@ import info.nukoneko.cuc.android.kidspos.testutil.fakeSettingsRepository import info.nukoneko.cuc.android.kidspos.ui.barcode.BarcodeEventBus import info.nukoneko.cuc.android.kidspos.ui.barcode.BarcodeInput import info.nukoneko.cuc.android.kidspos.util.BarcodeKind +import info.nukoneko.cuc.android.kidspos.util.Mode import java.io.IOException import kotlin.math.abs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -228,6 +231,74 @@ class MainScreenTest { ) } + @Test + @Config(qualifiers = "w960dp-h600dp-land-xhdpi") + fun calculatorFitsInsideNexus7Landscape() { + assertCalculatorFitsIn(600f) + } + + @Test + @Config(qualifiers = "w360dp-h640dp-port-xhdpi") + fun calculatorFitsInsideSmallPhonePortrait() { + assertCalculatorFitsIn(640f) + } + + @Test + fun accountResultShowsProgressWhileSaleIsInFlight() { + val gate = CompletableDeferred() + val defaultCreateSale = apiService.createSaleHandler + apiService.createSaleHandler = { args -> + gate.await() + defaultCreateSale(args) + } + runBlocking { settingsRepository.setRunningMode(Mode.PRODUCTION) } + openCalculator() + + pressKeys("5", "0", "0") + composeRule.onNodeWithTag(CalculatorAccountButtonTag).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag(AccountResultProgressTag).assertDoesNotExist() + + // ぐるぐるは終わらないアニメーションのため、waitForIdle が止まらなくなる + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithTag(AccountResultConfirmButtonTag).performClick() + composeRule.mainClock.advanceTimeBy(FRAME_STEP_MILLIS) + + composeRule.onNodeWithTag(AccountResultProgressTag).assertIsDisplayed() + composeRule.onNodeWithTag(AccountResultConfirmButtonTag).assertIsNotEnabled() + + gate.complete(Unit) + composeRule.mainClock.advanceTimeBy(FRAME_STEP_MILLIS) + composeRule.mainClock.autoAdvance = true + composeRule.waitForIdle() + + composeRule.onNodeWithTag(AccountResultProgressTag).assertDoesNotExist() + composeRule.onNodeWithTag(AccountResultConfirmButtonTag).assertDoesNotExist() + } + + private fun assertCalculatorFitsIn(screenHeight: Float) { + openCalculator() + pressKeys("5", "0", "0") + + val dialog = composeRule.onNodeWithTag(CalculatorDialogTag).getUnclippedBoundsInRoot() + assertTrue( + "ダイアログが画面からはみ出している: ${dialog.height.value} / $screenHeight", + dialog.height.value <= screenHeight + ) + + val footer = composeRule.onNodeWithTag(CalculatorAccountButtonTag) + .getUnclippedBoundsInRoot() + assertTrue( + "かいけいボタンがダイアログの外にある: ${footer.bottom.value} / ${dialog.bottom.value}", + footer.bottom.value <= dialog.bottom.value + 1f + ) + + composeRule.onNodeWithTag(CalculatorAccountButtonTag).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.back)).assertIsDisplayed() + composeRule.onNodeWithText("0").assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.delete)).assertIsDisplayed() + } + private fun pressKeys(vararg keys: String) { keys.forEach { key -> composeRule.onNodeWithText(key).assertIsDisplayed().performClick() @@ -276,4 +347,8 @@ class MainScreenTest { composeRule.onNodeWithText("復活商品").assertIsDisplayed() } + + private companion object { + const val FRAME_STEP_MILLIS = 100L + } } diff --git a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModelTest.kt b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModelTest.kt index 3e0a94c..aa36155 100644 --- a/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModelTest.kt +++ b/app/src/testProd/kotlin/info/nukoneko/cuc/android/kidspos/ui/main/MainViewModelTest.kt @@ -14,6 +14,7 @@ import info.nukoneko.cuc.android.kidspos.ui.barcode.BarcodeEventBus import info.nukoneko.cuc.android.kidspos.ui.barcode.BarcodeInput import info.nukoneko.cuc.android.kidspos.util.BarcodeKind import info.nukoneko.cuc.android.kidspos.util.Mode +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -260,6 +261,63 @@ class MainViewModelTest { assertEquals("boom", viewModel.uiState.value.errorMessage) assertFalse(viewModel.uiState.value.items.isEmpty()) + assertEquals(false, viewModel.uiState.value.accountResult?.processing) + } + + @Test + fun accountResultStaysProcessingUntilSaleCompletes() = runTest { + val gate = CompletableDeferred() + val defaultCreateSale = apiService.createSaleHandler + apiService.createSaleHandler = { args -> + gate.await() + defaultCreateSale(args) + } + apiService.getItemHandler = { barcode -> + Item(id = 9, barcode = barcode, name = "item", price = 300, storeId = 1, genreId = 1) + } + settingsRepository.setRunningMode(Mode.PRODUCTION) + val viewModel = createViewModel() + emitBarcode("1001000001", BarcodeKind.ITEM) + viewModel.onAccountClick() + viewModel.enterDeposit(5, 0, 0) + viewModel.onCalculatorOk() + + viewModel.onAccountResultOk() + + assertEquals(true, viewModel.uiState.value.accountResult?.processing) + + viewModel.onAccountResultOk() + viewModel.onAccountResultBack() + + assertEquals(1, apiService.createSaleCalls.size) + assertEquals(true, viewModel.uiState.value.accountResult?.processing) + + gate.complete(Unit) + + assertNull(viewModel.uiState.value.accountResult) + assertTrue(viewModel.uiState.value.items.isEmpty()) + } + + @Test + fun accountResultStopsProcessingWhenSaleFails() = runTest { + apiService.createSaleHandler = { throw RuntimeException("boom") } + apiService.getItemHandler = { barcode -> + Item(id = 9, barcode = barcode, name = "item", price = 300, storeId = 1, genreId = 1) + } + settingsRepository.setRunningMode(Mode.PRODUCTION) + val viewModel = createViewModel() + emitBarcode("1001000001", BarcodeKind.ITEM) + viewModel.onAccountClick() + viewModel.enterDeposit(5, 0, 0) + viewModel.onCalculatorOk() + + viewModel.onAccountResultOk() + + assertEquals(false, viewModel.uiState.value.accountResult?.processing) + + viewModel.onAccountResultOk() + + assertEquals(2, apiService.createSaleCalls.size) } @Test