fix: let operators leave scans with unconfirmed results

Replace the permanent settled-scan navigation block with an explicit warning.
Keep the original retry identity through failed cancellation, and provide a
separate Leave anyway choice only after cancellation fails. In-flight work,
pending photos and complete receipt checks remain protected.

Add JVM and native regressions for lost or malformed acknowledgments, Stay,
cancel retries and explicit local exit without replaying a scan.

Co-Authored-By: GPT-6.1 Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandGPT-6.1 Codex committed 2026-10-31 15:51:37 -05:00
1 parent 459cc1faec
commit d1de0bfc70
11 files changed
+262 -13

No files matched your search

+3 -1
View File
@@ -1,6 +1,6 @@
# Native navigation and OAuth2 acceptance
On October 31, 2026, the separate sample application test package ran on the physical Zebra TC22. The terminal instrumentation summary was `OK (20 tests)`, elapsed 49.235 seconds, with `INSTRUMENTATION_CODE: -1` (the normal successful Android instrumentation result). No operator identifier, barcode, credential or raw device log is retained here.
On October 31, 2026, the separate sample application test package ran on the physical Zebra TC22. The latest terminal instrumentation summary was `OK (21 tests)`, elapsed 53.482 seconds, with `INSTRUMENTATION_CODE: -1` (the normal successful Android instrumentation result). No operator identifier, barcode, credential or raw device log is retained here.
The selected classes were NavigationTests, SemanticInputTests, SessionTests and OAuth2LoginTests. They cover the signed-out drawer, Back and guarded app/process navigation, secondary Settings/Diagnostics, input/account behavior, and seven OAuth2 cases:
@@ -14,4 +14,6 @@ The selected classes were NavigationTests, SemanticInputTests, SessionTests and
The initial run passed 19 cases and exposed Back during drawer-opening animation. The handler now consumes Back while the drawer is open or targeting Open; the test helper settles the UI before issuing hardware Back. The rebuilt suite passed all 20 cases. The follow-up bounded build also passed 102 JVM tests, scanner lint, host debug/E2E APK assembly and native-test compilation in 1m3s (266 tasks, 34 executed).
The latest 21-case run adds the unconfirmed-scan regression: a lost response and malformed receipt retain the original event; Stay preserves it; failed cancellation leaves the operator a choice to retry or explicitly Leave anyway; the final local exit reaches the app and Apps catalog without an extra scan submission or cancellation. The bounded fix build passed all 46 mobile JVM tests, scanner lint/debug APK and sample/test APK assembly in 18 seconds. Pending-photo and active-request guards remain enforced by regression tests. The native fixture simulates response and cancellation failures; it does not claim a server rollback.
This is actual handheld execution with a synthetic QQQ repository, injected HTTPS discovery metadata and an instrumentation monitor returning AppAuth activity results. It does not establish a live Authentik login, an actual browser redirect through the exported callback activity, browser-authorization rotation/process-death behavior or configured shared-operator reauthentication. Those remain deployment acceptance gates in [AUTHENTICATION.md](AUTHENTICATION.md). This run did not replace the normal operator scanner app or modify its data. No production signing or release delivery is implied.
+5 -1
View File
@@ -39,7 +39,9 @@ The owner also requires a device-local Orientation preference in Settings: Syste
| Completed process | Close to the containing app; no cancellation request for completed work. | Close and go to requested destination. |
| Active process, including scanner | Ask to leave the process. Stay preserves work. Confirm uses native cancellation then navigates. Saved scans remain saved; never imply Cancel undoes committed records. | Same leave policy, remembering the requested destination. |
| Initializing process | Same policy; cancellation intent survives a late process UUID and cancels that process before navigation completes. | Same policy. |
| Submission/upload in flight or outcome uncertain | Keep the operation identity and present progress or Check result. Do not silently reset it or replay an ordinary mutation. Any explicit discard/leave must describe uncertainty and preserve the server reconciliation identity. | Same policy; no hidden background second process. |
| Submission/upload in flight or pending photo | Keep the operation identity and block leaving until the request settles or the pending photo is resolved. | Same policy; no hidden background second process. |
| Settled scan with missing acknowledgment | Warn that the scan may already be saved. Stay retains the original event for an explicit same-event retry; Leave process stops retrying and requests server cancellation. Retain the event if cancellation fails, and discard it only after confirmed cancellation. | Same explicit confirmation and cancellation policy. |
| Cancellation failed | Stay retains work; retry cancellation or explicitly choose Leave anyway. Warn that the server process may still run and the scan may already be saved. Leave anyway discards local retry state and applies the requested destination without another server request. In-flight and pending-photo guards still apply. | Same second-stage explicit choice; never an automatic fallback. |
| Settings/diagnostics/account surface | Dismiss the surface, preserving current app/process and draft. | Close secondary surface, then apply the normal primary navigation policy. |
The top-level Back arrow means navigation out of the current process, not undoing a scan or automatically moving to a previous business step. Label the leave dialog accordingly, for example **Leave Scan Capture?** with **Stay** and **Leave process**. Completed scans stay saved. For multi-step forms, render a server-supported previous-step action within the process when available; never guess it from an arrow or step name.
@@ -57,6 +59,8 @@ Use labeled actions with accessible names, at least 48dp touch targets, readable
- The hamburger remains available beside Back inside processes; duplicate Apps and Switch app controls are absent. Drawer dismissal preserves drafts and makes no cancellation request. All apps and direct app switching use the same guard and land at the requested destination; Back never resurrects the old process.
- Signed-out and pending-approval drawers expose Settings and Diagnostics without business navigation. Secondary entries stay visible below the scrolling app list.
- Completed result closes without cancel; initializing process cancellation handles its late UUID; uncertain submission does not replay an ordinary mutation or discard retry identity.
- A lost scan response or malformed receipt offers an enabled Leave process action after the request settles. Stay preserves the event; confirmed leave cancels once without resubmitting. Failed cancellation retains the event and retry option. In-flight requests and pending photos still block leave.
- Failed cancellation exposes Leave anyway only after a real attempt. Stay retains the event; cancellation retry remains available; explicitly leaving anyway clears local state once without another mutation. Verify the fallback cannot bypass in-flight requests or pending photos.
- Settings, account and diagnostics open/close without navigating away from or submitting the process; server change uses the leave/sign-out guard.
- Explicit sign-out Cancel preserves work; Confirm clears protected content and returns to login; fresh login returns to Apps with enrollment retained.
- Remote session end bypasses confirmation and prevents stale Back navigation/capture.
+4
View File
@@ -1,5 +1,9 @@
# Session state
## October 31 unconfirmed scan exit correction
The operator reported Leave process permanently disabled with “The last scan is not confirmed.” The existing JVM harness reproduced that settled missing receipts remained a hard navigation block. A new regression failed before the correction. Settled unconfirmed scans now show an explicit warning that they may already be saved; Stay retains the original retry event, while Leave process uses the existing server cancellation and clears the event after cancellation succeeds. Failed cancellation retains the event and exposes a separate, explicit Leave anyway choice that clears local state without another request; the dialog warns that the server process may still run and saved changes are not undone. Active requests and pending photos remain hard blockers, and receipt validation is unchanged. Added JVM guards/cancellation/fallback cases and a native lost-response/malformed-receipt/Stay/failed-cancellation/explicit-exit regression. Final bounded verification passed all 46 mobile JVM tests, scanner lint/debug APK and sample/test APK assembly in 18 seconds. The TC22 follow-up passed all 21 selected native cases in 53.482 seconds, including the new lost-response/failed-cancellation/explicit-exit case. The operator update is being coordinated separately; actual configured Authentik acceptance remains pending.
## October 31 navigation/authentication review checkpoint
The combined client checkpoint passed 102 JVM tests (26 core, 43 mobileapp, 33 scanner), scanner lint, debug/E2E APK assembly and native-test compilation. The final follow-up build passed in 1m3s (266 tasks, 34 executed). The TC22 rerun passed all 20 selected navigation/input/account/OAuth tests in 49.235 seconds, including all seven synthetic AppAuth cases. The first run exposed Back during drawer opening; the handler now also handles a drawer targeting Open, and the native helper waits for settled UI before hardware Back. [Native acceptance evidence](NATIVE-AUTH-ACCEPTANCE.md) distinguishes real handheld execution from the simulated browser/provider. No actual Authentik sign-in is claimed. New hosted CI is required after publication.
+3
View File
@@ -1,5 +1,8 @@
# TODO
- [x] Verify the unconfirmed-scan exit correction: 46 mobile JVM tests and all 21 selected TC22 native cases pass, including failed cancellation and explicit Leave anyway with preserved receipt/retry identity and in-flight/photo guards.
- [ ] Finish the operator walkthrough of the unconfirmed-scan exit correction on the normal scanner installation.
- [x] Qualify semantic OAuth2 client protocol/policy and generated notices; all seven synthetic browser-result cases and the selected native navigation suite passed on TC22. See [acceptance evidence](NATIVE-AUTH-ACCEPTANCE.md).
- [ ] Complete actual configured Authentik acceptance: real browser/exported-callback round trip, authorization rotation/process death, and next-operator switching with prior browser SSO. See [authentication deployment](AUTHENTICATION.md).
- [x] Verify the single hamburger drawer correction: no duplicate Apps/Switch app controls, fixed Settings/Diagnostics, signed-out gating, drawer Back preserving drafts, and guarded Home/app switching with exactly one cancellation.
+1 -1
View File
@@ -59,7 +59,7 @@ Core structured errors retain their code, message, field errors and trace ID. Fi
Record/table browsing and CRUD, reports, app-home widgets, record-list process components, bulk edit/load mapping/review, downloads, remote possible-value lookup, blob input and other specialized components are not implemented by this host. Rich web layout, advanced field presentation and section collapse/role presentation also do not have full native parity. Host authentication supports TABLE_BASED and is adding explicitly advertised native OAuth2 session login through UI API1.9; see [the contract and pending verification](AUTHENTICATION.md). Keep handheld processes within the supported component vocabulary until their native implementation and end-to-end tests are added.
The scanner event preserves the barcode string, event UUID and capture timestamp. A retry sends the same event. Clearing the pending retry requires a matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank string `scanId`. Missing or contradictory receipt fields leave the same event available to retry. An explicitly invalid SCANNER `maxLength` disables the control; only an absent value uses the default. Scanner retry context is explicit; a generic process field named `eventId` does not activate scanner acknowledgment behavior. Device approval and user permissions remain server decisions, including scans submitted through an already-open process.
The scanner event preserves the barcode string, event UUID and capture timestamp. A retry sends the same event. Successful acknowledgment requires a matching `acknowledgedEventId`, `scanSaved: true`, and a nonblank string `scanId`. Missing or contradictory receipt fields leave the same event available to retry. After the request settles, the operator may explicitly leave with a warning that the scan may already be saved. Confirmed server cancellation clears local retry state; failed cancellation preserves it and offers a separate Leave anyway action to abandon local retry without claiming cancellation or undoing saved records. In-flight requests and pending photos still block leaving. An explicitly invalid SCANNER `maxLength` disables the control; only an absent value uses the default. Scanner retry context is explicit; a generic process field named `eventId` does not activate scanner acknowledgment behavior. Device approval and user permissions remain server decisions, including scans submitted through an already-open process.
## Session activity and screen viewing
@@ -80,6 +80,18 @@ class QNavigator(initialController: NavHostController?, val qViewModel: QViewMod
finishLeave(request.action)
}
/** A second explicit choice after failed cancellation; this does not claim the server stopped. */
fun leaveAnyway()
{
val request = leaveRequest ?: return
val process = qViewModel.activeProcessViewModel ?: return
if(!process.cancellationFailed || process.cancelling || process.navigationBlockReason() != null) return
process.reset()
qViewModel.activeProcessViewModel = null
leaveRequest = null
request.action()
}
private fun finishLeave(action: () -> Unit)
{
val process = qViewModel.activeProcessViewModel
@@ -48,14 +48,21 @@ internal fun LeaveProcessDialog(model: QViewModel)
if(process != null && !process.getNoMoreSteps())
{
Text("Leaving cancels this process. Unsaved input will be lost. Saved scans and other committed records stay saved.")
if(process.unconfirmedSubmission)
process.navigationWarning()?.let { Text(it) }
if(process.unconfirmedSubmission && process.pendingStepValues == null)
Text("The last request's result is unknown. Cancellation does not undo saved changes. Ask your administrator to check process ${process.processUUID}.")
}
else if(request.confirmLabel == "Sign out") Text("Sign out of this account on this device?")
blocked?.let { Text(it) }
if(cancelling) Text("Waiting for the server to confirm cancellation…")
if(process?.cancellationFailed == true)
Text("The server did not confirm cancellation. You can retry cancellation or leave anyway. The server process may still be running and the scan may already be saved. Leaving anyway stops local retries; it does not undo saved changes.")
(process?.topLevelErrorState as? LoadState.Error)?.message?.let { Text(it) }
} },
confirmButton = { TextButton(onClick = navigator::confirmLeave, enabled = blocked == null && !cancelling) { Text(request.confirmLabel) } },
dismissButton = { TextButton(onClick = navigator::dismissLeave, enabled = !cancelling) { Text("Stay") } })
dismissButton = { Column {
if(process?.cancellationFailed == true)
TextButton(onClick = navigator::leaveAnyway, enabled = blocked == null && !cancelling) { Text("Leave anyway") }
TextButton(onClick = navigator::dismissLeave, enabled = !cancelling) { Text("Stay") }
} })
}
@@ -64,6 +64,8 @@ open class ProcessViewModel : ViewModel()
private set
var cancelling: Boolean by mutableStateOf(false)
private set
var cancellationFailed: Boolean by mutableStateOf(false)
private set
var submissionError: String? by mutableStateOf(null)
private set
private var processComplete: Boolean by mutableStateOf(false)
@@ -79,15 +81,19 @@ open class ProcessViewModel : ViewModel()
var unconfirmedSubmission: Boolean by mutableStateOf(false)
private set
/** Native navigation never interrupts a submitted mutation or forgets a scan receipt. */
/** Native navigation never interrupts a submitted mutation or pending native work. */
fun navigationBlockReason(): String? = when
{
clientWorkBlockReason != null -> clientWorkBlockReason
waitingOnBackend && processUUID != null && initLoadState !is LoadState.Loading -> "Wait for the running request to finish before leaving."
pendingStepValues != null -> "The last scan is not confirmed. Stay and retry the same request before leaving."
else -> null
}
/** Leaving is an explicit choice; a missing receipt is not proof that the scan was rejected. */
fun navigationWarning(): String? = if(pendingStepValues != null)
"The last scan is not confirmed and may already be saved. Stay to retry the same scan, or leave to stop retrying. Leaving does not undo a saved scan."
else null
fun requestClose()
{
if(::qViewModel.isInitialized) qViewModel.qNavigator.popStack() else cancel()
@@ -250,6 +256,7 @@ open class ProcessViewModel : ViewModel()
pendingAcknowledgment = null
fieldErrors = emptyMap()
cancelling = false
cancellationFailed = false
submissionError = null
processComplete = false
processMetaDataLoadState = LoadState.Loading(Unit)
@@ -332,6 +339,7 @@ open class ProcessViewModel : ViewModel()
catch(e: Exception)
{
waitingOnBackend = false
cancellationFailed = cancelling
initLoadState = LoadState.Error(e, if(cancelling)
"The server did not confirm cancellation. The process may still be running. Retry cancellation if available, or contact your administrator."
else semanticError(e, "Error launching process."))
@@ -446,7 +454,7 @@ open class ProcessViewModel : ViewModel()
}
/** Retain only the current request in memory so an uncertain response can be retried safely. */
var pendingStepValues: Map<String, Any?>? = null
var pendingStepValues: Map<String, Any?>? by mutableStateOf(null)
private set
private var pendingAcknowledgment: ProcessAcknowledgment? = null
@@ -638,6 +646,7 @@ open class ProcessViewModel : ViewModel()
fun cancel()
{
if(cancelling) return
cancellationFailed = false
val uuid = processUUID
if(uuid == null && initializing)
{
@@ -657,11 +666,14 @@ open class ProcessViewModel : ViewModel()
try
{
qqqRepository.processCancel(processName, uuid)
pendingStepValues = null
pendingAcknowledgment = null
closeProcessCallback?.invoke()
}
catch(e: kotlinx.coroutines.CancellationException) { throw e }
catch(e: Exception)
{
cancellationFailed = true
waitingOnBackend = false
setTopLevelError(if(e is com.kingsrook.qqq.frontend.android.core.controllers.UiApiException) e.message ?: "Cancellation failed." else "The server did not confirm cancellation. Try again when connected.")
}
@@ -6,6 +6,10 @@ import com.kingsrook.qqq.frontend.android.core.model.Environment
import com.kingsrook.qqq.frontend.android.core.model.metadata.*
import com.kingsrook.qqq.frontend.android.core.model.metadata.authentication.*
import com.kingsrook.qqq.frontend.android.core.model.processes.*
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import com.kingsrook.qqq.frontend.android.mobileapp.container.QAppContainer
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CompletableDeferred
@@ -43,7 +47,7 @@ class ProcessSubmissionTest
finally { Dispatchers.resetMain() }
}
@Test fun missingScanReceiptBlocksLeavingUntilSameRequestIsAcknowledged() = runTest()
@Test fun settledMissingScanReceiptAllowsConfirmedLeaveWithoutDiscardingRetry() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try
@@ -52,14 +56,83 @@ class ProcessSubmissionTest
val model = model(repository)
model.doInit()
advanceUntilIdle()
assertNull(model.navigationBlockReason())
assertNull(model.navigationWarning())
model.runStep(mapOf("eventId" to "scan"), ProcessAcknowledgment("acknowledgedEventId", "scan"))
advanceUntilIdle()
assertNotNull(model.navigationBlockReason())
assertNull(model.navigationBlockReason())
assertEquals("scan", model.pendingStepValues?.get("eventId"))
assertTrue(model.navigationWarning()!!.contains("may already be saved"))
repository.responseValues = mapOf("acknowledgedEventId" to "scan")
model.retryPendingStep()
advanceUntilIdle()
assertNull(model.navigationBlockReason())
assertNull(model.pendingStepValues)
assertNull(model.navigationWarning())
}
finally { Dispatchers.resetMain() }
}
@Test fun lostScanResponseAllowsLeaveOnlyAfterRequestSettlesAndPreservesPendingPhotos() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try
{
val gate = CompletableDeferred<Unit>()
val repository = SubmissionRepository().apply { stepGate = gate; stepFailures = 1 }
val model = model(repository)
model.doInit()
advanceUntilIdle()
model.runStep(mapOf("eventId" to "scan"), ProcessAcknowledgment("acknowledgedEventId", "scan"))
runCurrent()
assertNotNull(model.navigationBlockReason())
gate.complete(Unit)
advanceUntilIdle()
assertNull(model.navigationBlockReason())
assertNotNull(model.navigationWarning())
assertEquals("scan", model.pendingStepValues?.get("eventId"))
model.setClientWork("photo", "Finish the pending photo first.")
assertEquals("Finish the pending photo first.", model.navigationBlockReason())
model.setClientWork("photo", null)
assertNull(model.navigationBlockReason())
assertEquals(1, repository.submissions.size)
}
finally { Dispatchers.resetMain() }
}
@Test fun confirmedLeaveKeepsOriginalScanUntilCancellationSucceedsAndNeverResubmits() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try
{
val gate = CompletableDeferred<Unit>()
val repository = SubmissionRepository().apply { cancelGate = gate; cancelFailures = 1 }
val model = model(repository)
var closes = 0
model.closeProcessCallback = { closes++ }
model.doInit()
advanceUntilIdle()
model.runStep(mapOf("eventId" to "scan"), ProcessAcknowledgment("acknowledgedEventId", "scan"))
advanceUntilIdle()
model.cancel()
runCurrent()
model.cancel()
assertEquals(1, repository.cancellations)
assertEquals("scan", model.pendingStepValues?.get("eventId"))
gate.complete(Unit)
advanceUntilIdle()
assertEquals(0, closes)
assertTrue(model.cancellationFailed)
assertEquals("scan", model.pendingStepValues?.get("eventId"))
assertNotNull(model.navigationWarning())
model.cancel()
assertFalse(model.cancellationFailed)
advanceUntilIdle()
assertEquals(1, closes)
assertEquals(2, repository.cancellations)
assertEquals(1, repository.submissions.size)
assertNull(model.pendingStepValues)
assertNull(model.navigationWarning())
}
finally { Dispatchers.resetMain() }
}
@@ -280,6 +353,63 @@ class ProcessSubmissionTest
finally { Dispatchers.resetMain() }
}
@Test fun explicitLocalLeaveRequiresFailedCancellationAndHonorsWorkGuards() = runTest()
{
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
try
{
val repository = SubmissionRepository().apply { cancelFailures = 2 }
val process = model(repository)
QAppContainer.availableEnvironments = listOf(Environment("Test", "https://example.invalid"))
val host = QViewModel(repository, MockPreferencesDataStore())
// This policy fixture owns only navigation; skip Android-backed startup/authentication work.
host.viewModelScope.coroutineContext.cancelChildren()
host.activeProcessViewModel = process
process.doInit()
advanceUntilIdle()
process.runStep(mapOf("eventId" to "scan"), ProcessAcknowledgment("acknowledgedEventId", "scan"))
advanceUntilIdle()
var destinations = 0
host.qNavigator.requestLeave("Leave?") { destinations++ }
host.qNavigator.leaveAnyway()
assertEquals(0, destinations)
host.qNavigator.confirmLeave()
advanceUntilIdle()
assertTrue(process.cancellationFailed)
host.qNavigator.dismissLeave()
assertEquals("scan", process.pendingStepValues?.get("eventId"))
assertEquals(0, destinations)
host.qNavigator.requestLeave("Leave?") { destinations++ }
host.qNavigator.confirmLeave()
assertFalse(process.cancellationFailed)
host.qNavigator.leaveAnyway()
assertEquals(0, destinations)
advanceUntilIdle()
process.setClientWork("photo", "Pending photo")
host.qNavigator.leaveAnyway()
assertEquals(0, destinations)
process.setClientWork("photo", null)
val stepGate = CompletableDeferred<Unit>()
repository.stepGate = stepGate
process.retryPendingStep()
runCurrent()
host.qNavigator.leaveAnyway()
assertEquals(0, destinations)
stepGate.complete(Unit)
advanceUntilIdle()
host.qNavigator.leaveAnyway()
host.qNavigator.leaveAnyway()
assertEquals(1, destinations)
assertEquals(2, repository.cancellations)
assertEquals(2, repository.submissions.size)
assertEquals(repository.submissions.first(), repository.submissions.last())
assertNull(process.pendingStepValues)
assertNull(host.activeProcessViewModel)
assertFalse(process.cancellationFailed)
}
finally { Dispatchers.resetMain() }
}
private fun model(repository: SubmissionRepository) = ProcessViewModel().apply()
{
qqqRepository = repository
@@ -298,6 +428,8 @@ class ProcessSubmissionTest
var initGate: CompletableDeferred<Unit>? = null
var stepGate: CompletableDeferred<Unit>? = null
var stepFailures = 0
var cancelGate: CompletableDeferred<Unit>? = null
var cancelFailures = 0
var asynchronous = false
var statusFailures = 0
var statusCalls = 0
@@ -323,7 +455,12 @@ class ProcessSubmissionTest
if(stepFailures-- > 0) throw java.io.IOException("Response lost")
return if(asynchronous) QJobStarted("process", "job") else QJobComplete("process", responseValues, nextStep)
}
override suspend fun processCancel(processName: String, processUUID: String) { cancellations++ }
override suspend fun processCancel(processName: String, processUUID: String)
{
cancellations++
cancelGate?.await()
if(cancelFailures-- > 0) throw java.io.IOException("Cancellation response lost")
}
override suspend fun processJobStatus(processName: String, processUUID: String, jobUUID: String): ProcessStepResult
{
statusCalls++
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.performTextReplacement
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.navigation.compose.rememberNavController
import androidx.test.platform.app.InstrumentationRegistry
import com.kingsrook.qqq.frontend.android.mobileapp.QMobileAppMain
@@ -23,6 +24,7 @@ import com.kingsrook.qqq.frontend.android.core.controllers.QQQRepository
import com.kingsrook.qqq.frontend.android.core.model.metadata.*
import com.kingsrook.qqq.frontend.android.core.model.processes.QJobComplete
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.QViewModel
import com.kingsrook.qqq.frontend.android.mobileapp.viewmodel.ProcessAcknowledgment
import com.kingsrook.qqq.frontend.android.mobileapp.data.MockPreferencesDataStore
import java.util.concurrent.atomic.AtomicInteger
import org.junit.Assert.assertEquals
@@ -157,6 +159,72 @@ class NavigationTests : BaseTest()
assertNull(model.activeProcessViewModel)
}
@Test fun unconfirmedScanCanStayOrExplicitlyLeaveWithoutResubmitting()
{
val cancellations = AtomicInteger()
val submissions = AtomicInteger()
val base = SampleAppMockQQQRepository()
val repository = object : QQQRepository by base
{
override suspend fun getProcessMetaData(processName: String) = QProcessMetaData("process1", "Process 1", frontendSteps = listOf(
QFrontendStepMetaData("input", "Input", components = listOf(QFrontendComponent(QComponentType.EDIT_FORM)),
formFields = listOf(QFieldMetaData("reference", "Reference", type = QFieldType.STRING)))))
override suspend fun processInit(processName: String, values: Map<String, Any?>, recordsParam: String?, recordIds: String?, filterJSON: String?, stepTimeoutMillis: Int?) = QJobComplete("scan-leave-process", nextStep = "input")
override suspend fun processStep(processName: String, processUUID: String, stepName: String, values: Map<String, Any?>, stepTimeoutMillis: Int?): QJobComplete
{
if(submissions.incrementAndGet() == 1) throw java.io.IOException("Synthetic response loss")
return QJobComplete(processUUID, values = mapOf("acknowledgedEventId" to "wrong-event"), nextStep = "input")
}
override suspend fun processCancel(processName: String, processUUID: String)
{
cancellations.incrementAndGet()
throw java.io.IOException("Synthetic cancellation unavailable")
}
}
val model = QViewModel(repository, MockPreferencesDataStore())
TestUtils.startStandardTest(ui, model)
TestUtils.login(ui)
ui.onNodeWithTag("appHome.rowForApp:app1").performClick()
ui.onNodeWithTag("appHome.rowForProcess:process1").performClick()
ui.waitUntil(5000) { model.activeProcessViewModel?.activeStepName == "input" }
val process = model.activeProcessViewModel!!
ui.runOnIdle { process.runStep(mapOf("eventId" to "scan-leave-event"), ProcessAcknowledgment("acknowledgedEventId", "scan-leave-event")) }
ui.waitUntil(5000) { !process.waitingOnBackend && process.pendingStepValues != null }
ui.onNodeWithContentDescription("Go Back").performClick()
ui.onNodeWithText("The last scan is not confirmed and may already be saved.", substring = true).assertIsDisplayed()
ui.onNodeWithText("Leave process").assertIsEnabled()
ui.onNodeWithText("Leave anyway").assertDoesNotExist()
ui.onNodeWithText("Stay").performClick()
ui.runOnIdle {
assertEquals("scan-leave-event", process.pendingStepValues?.get("eventId"))
assertEquals(0, cancellations.get())
process.retryPendingStep()
}
ui.waitUntil(5000) { submissions.get() == 2 && !process.waitingOnBackend }
ui.onNodeWithTag("topBar.menuIcon").performClick()
ui.onNodeWithTag("drawer.home").performClick()
ui.onNodeWithText("The last scan is not confirmed and may already be saved.", substring = true).assertIsDisplayed()
ui.onNodeWithText("Leave process").assertIsEnabled().performClick()
ui.waitUntil(5000) { process.cancellationFailed && !process.cancelling }
ui.onNodeWithText("Leave anyway").assertIsEnabled()
ui.onNodeWithText("Stay").performClick()
ui.runOnIdle {
assertEquals("scan-leave-event", process.pendingStepValues?.get("eventId"))
assertEquals(1, cancellations.get())
}
ui.onNodeWithContentDescription("Go Back").performClick()
ui.onNodeWithText("Leave process").performClick()
ui.waitUntil(5000) { cancellations.get() == 2 && process.cancellationFailed && !process.cancelling }
ui.onNodeWithText("Leave anyway").assertIsEnabled().performClick()
ui.waitUntil(5000) { model.activeProcessViewModel == null }
assertEquals("app1", model.qNavigator.currentAppName)
ui.onNodeWithContentDescription("Go Back").performClick()
ui.waitUntil(5000) { model.qNavigator.atHome && model.activeProcessViewModel == null }
assertEquals(2, cancellations.get())
assertEquals(2, submissions.get())
assertNull(process.pendingStepValues)
}
@Test fun systemBackAndToolbarBackUseTheActualDestination()
{
TestUtils.startStandardTest(ui)
+2 -2
View File
@@ -124,8 +124,8 @@ export async function runScannerE2e(options = {}) {
const evidence = join(repository, 'scanner-host/build/native-device-e2e');
await mkdir(evidence, { recursive: true });
await writeFile(join(evidence, 'navigation-instrumentation.txt'), output);
if (!/OK \(20 tests\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Physical native navigation/input/account/OAuth acceptance failed; inspect the isolated test evidence.');
console.log('Physical native navigation/input/account/OAuth acceptance passed (20 tests, synthetic repository and browser result).');
if (!/OK \(21 tests\)/.test(output) || /FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed/.test(output)) throw new Error('Physical native navigation/input/account/OAuth acceptance failed; inspect the isolated test evidence.');
console.log('Physical native navigation/input/account/OAuth acceptance passed (21 tests, synthetic repository and browser result).');
} finally {
for (const installed of temporary.reverse()) await adbRun('uninstall', installed);
}