From 09083f589b3259edeccb95f7b0dff15ed86f2bae Mon Sep 17 00:00:00 2001 From: GonzRon Date: Mon, 5 Oct 2026 14:48:35 -0400 Subject: [PATCH 1/5] loopback stop test: end of stream on the closed port is not an answer --- .../servicetag/api/LoopbackApiServerTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/loosecannon/servicetag/api/LoopbackApiServerTest.kt b/app/src/test/kotlin/com/loosecannon/servicetag/api/LoopbackApiServerTest.kt index 55d333f2..a67aa2eb 100644 --- a/app/src/test/kotlin/com/loosecannon/servicetag/api/LoopbackApiServerTest.kt +++ b/app/src/test/kotlin/com/loosecannon/servicetag/api/LoopbackApiServerTest.kt @@ -43,6 +43,9 @@ import org.junit.Test private const val TOKEN = "ABCD2345" +/** What `InputStream.read()` returns at the end of the stream. */ +private const val END_OF_STREAM = -1 + /** * 1.1.0 (#46) — the listener itself, over a real loopback socket, on the JVM. * @@ -161,12 +164,15 @@ class LoopbackApiServerTest { server.stop() assertEquals(0, server.boundPort) - try { + val first = try { Socket("127.0.0.1", port).use { it.getInputStream().read() } - fail("the port is still answering after stop()") } catch (e: IOException) { // Refused, which is the whole point of the lifecycle binding. + END_OF_STREAM } + // A runner's stack can accept the connect and close it straight away: end of stream with no byte is nobody + // answering. One byte is an answer. + if (first != END_OF_STREAM) fail("the port is still answering after stop()") } /** What the screen's third line counts: answers given, refusals included. */ From f5b1d2e37917d4920353e878c01f4b40f549430e Mon Sep 17 00:00:00 2001 From: GonzRon Date: Mon, 5 Oct 2026 14:56:03 -0400 Subject: [PATCH 2/5] device tests: date fields draw the phone's order, expect it through dateFieldText --- .../loosecannon/servicetag/ui/AssetModelDeviceProofTest.kt | 3 ++- .../com/loosecannon/servicetag/ui/JournalDeviceProofTest.kt | 6 ++++-- .../servicetag/ui/asset/AssetEditorSeasonAndHealthTest.kt | 2 +- .../servicetag/ui/condition/ChangeConditionSheetTest.kt | 3 ++- .../servicetag/ui/service/ServiceCaseScreensTest.kt | 3 ++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/AssetModelDeviceProofTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/AssetModelDeviceProofTest.kt index 21d53bdf..28fa5ac2 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/AssetModelDeviceProofTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/AssetModelDeviceProofTest.kt @@ -27,6 +27,7 @@ import androidx.test.core.app.ApplicationProvider import com.loosecannon.servicetag.MainActivity import com.loosecannon.servicetag.core.model.AssetId import com.loosecannon.servicetag.core.usecase.AssetCommand +import com.loosecannon.servicetag.l10n.dateFieldText import com.loosecannon.servicetag.ui.asset.NO_PARENT import kotlinx.coroutines.runBlocking import org.junit.Before @@ -286,7 +287,7 @@ class AssetModelDeviceProofTest { // The date field opens on today, which the user may then backdate. rule.awaitText("Retire this asset?") - rule.onNodeWithText(LocalDate.now().toString()).assertIsDisplayed() + rule.onNodeWithText(dateFieldText(LocalDate.now().toString())).assertIsDisplayed() rule.onNodeWithText("Retire").performClick() // The offer comes after the write, so declining it leaves the asset retired. diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/JournalDeviceProofTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/JournalDeviceProofTest.kt index 6f9265a9..9b0513f6 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/JournalDeviceProofTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/JournalDeviceProofTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.performTextReplacement import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import com.loosecannon.servicetag.MainActivity +import com.loosecannon.servicetag.l10n.dateFieldText import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Before @@ -452,8 +453,9 @@ private fun ComposeTestRule.logWaterTest( awaitTag("value-ph") if (on != null) { // Backdating is ordinary (§4): the date is a plain field holding today until it is changed. - onNode(hasSetTextAction() and hasText(LocalDate.now().toString())) - .performTextReplacement(on.toString()) + // #102: the field draws and reads the phone's own order, so the day is found and typed that way. + onNode(hasSetTextAction() and hasText(dateFieldText(LocalDate.now().toString()))) + .performTextReplacement(dateFieldText(on.toString())) } onNodeWithTag("value-ph").performTextReplacement(ph) entryList().performScrollToNode(hasTestTag("value-free_chlorine")) diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/asset/AssetEditorSeasonAndHealthTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/asset/AssetEditorSeasonAndHealthTest.kt index 644800d1..5ea429de 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/asset/AssetEditorSeasonAndHealthTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/asset/AssetEditorSeasonAndHealthTest.kt @@ -285,7 +285,7 @@ class AssetEditorSeasonAndHealthTest { rule.awaitText("Snowblower") rule.onNodeWithText(SAME_DATES_EVERY_YEAR).performScrollTo().assertIsSelected() field(SEASON_STARTS).performScrollTo() - .assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("11-01"))) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(monthDayFieldText("11-01")))) rule.onAllNodesWithText("Off means this asset is only in use", substring = true).assertCountEquals(0) rule.onAllNodesWithText("Set both season dates or neither").assertCountEquals(0) } diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/condition/ChangeConditionSheetTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/condition/ChangeConditionSheetTest.kt index f6b1ddd9..0c9e46d8 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/condition/ChangeConditionSheetTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/condition/ChangeConditionSheetTest.kt @@ -19,6 +19,7 @@ import com.loosecannon.servicetag.core.model.AssetId import com.loosecannon.servicetag.core.model.OperationalCondition import com.loosecannon.servicetag.core.usecase.AssetCommand import com.loosecannon.servicetag.di.AppGraph +import com.loosecannon.servicetag.l10n.dateFieldText import com.loosecannon.servicetag.ui.app import com.loosecannon.servicetag.ui.awaitText import com.loosecannon.servicetag.ui.clearInstall @@ -88,7 +89,7 @@ class ChangeConditionSheetTest { rule.onAllNodes(isSelected()).assertCountEquals(0) rule.onNodeWithText("Save condition").assertIsNotEnabled() field("What is wrong? (optional)").assertIsDisplayed() - field("When did this change?").assert(hasText(LocalDate.now().toString())) + field("When did this change?").assert(hasText(dateFieldText(LocalDate.now().toString()))) rule.onNodeWithText("Down").performClick() field("What is wrong? (optional)").performTextInput("Will not start") diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/service/ServiceCaseScreensTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/service/ServiceCaseScreensTest.kt index f2392910..0121363d 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/service/ServiceCaseScreensTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/service/ServiceCaseScreensTest.kt @@ -35,6 +35,7 @@ import com.loosecannon.servicetag.core.usecase.AssetCommand import com.loosecannon.servicetag.core.usecase.AttachmentResult import com.loosecannon.servicetag.core.usecase.EventCommand import com.loosecannon.servicetag.core.usecase.ServiceCaseCommand +import com.loosecannon.servicetag.l10n.dateFieldText import com.loosecannon.servicetag.ui.app import com.loosecannon.servicetag.ui.awaitText import com.loosecannon.servicetag.ui.clearInstall @@ -171,7 +172,7 @@ class ServiceCaseScreensTest { rule.onNodeWithText(ADD_UPDATE).performScrollTo().performClick() rule.awaitText(SAVE_UPDATE) field("Date").assertIsDisplayed() - rule.onNode(hasSetTextAction() and hasText(today)).assertIsDisplayed() + rule.onNode(hasSetTextAction() and hasText(dateFieldText(today))).assertIsDisplayed() field("Time").assertIsDisplayed() field(WHAT_HAPPENED).assertIsDisplayed() listOf(STATUS_SENT_OUT, STATUS_AT_THE_SERVICE_CENTER, STATUS_RETURNED, STATUS_CLOSED, STATUS_CANCELLED).forEach { From d324b2ad778e77e78b67af562858731409c65fef Mon Sep 17 00:00:00 2001 From: GonzRon Date: Mon, 5 Oct 2026 14:56:35 -0400 Subject: [PATCH 3/5] group screens test: reach the groups through the tab's maintenance groups row --- .../ui/maintenance/GroupScreensTest.kt | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/GroupScreensTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/GroupScreensTest.kt index 5d640e94..a2d7fa43 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/GroupScreensTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/GroupScreensTest.kt @@ -129,7 +129,21 @@ class GroupScreensTest { private fun openMaintenance() { rule.onNode(hasText("Maintenance") and hasClickAction()).performClick() - rule.awaitText("Maintenance groups") + rule.awaitText(GROUPS_SECTION) + } + + /** + * #103 (1.7.1): the groups left the tab for their own pushed list, so the way to a group is the + * person's way — the Maintenance tab, then the grouped section's "Maintenance groups" row, then the + * group's row on the list that opens. The list draws once the store has answered, which is what + * waiting for [group]'s row waits for — by its exact text, since the tab's due row carries the + * same name as its subtitle. + */ + private fun openGroupList(group: String = "North run") { + rule.onNode(hasTextExactly(GROUPS_SECTION) and hasClickAction()).performScrollTo().performClick() + rule.waitUntil(WAIT_MS) { + rule.onAllNodes(hasTextExactly(group) and hasClickAction()).fetchSemanticsNodes().isNotEmpty() + } } private fun openAssets() { @@ -144,10 +158,11 @@ class GroupScreensTest { @Test fun theListOpensAGroupAndAMemberRowOpensThatAssetsOwnScreen() { aStoreWithGroups() openMaintenance() + openGroupList() // `hasTextExactly` and not `hasText`: the group's name is also the **subtitle** of its - // schedule's due row, which is clickable too and opens the schedule. A list row says the - // group's name and nothing else, which is what tells the two apart. + // schedule's due row on the tab, which is clickable too and opens the schedule. A list row + // says the group's name and nothing else, which is what tells the two apart. rule.onNode(hasTextExactly("North run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("Maintenance group") @@ -201,6 +216,7 @@ class GroupScreensTest { @Test fun aMemberIsSoftRemovedAndReAddedWithTheListReadAtEachStep() { aStoreWithGroups() openMaintenance() + openGroupList("South run") rule.onNode(hasTextExactly("South run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("Maintenance group") @@ -249,6 +265,7 @@ class GroupScreensTest { aStoreWithGroups() openMaintenance() rule.awaitText("Head check") + openGroupList() rule.onNode(hasTextExactly("North run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("Maintenance group") @@ -257,9 +274,16 @@ class GroupScreensTest { rule.onNodeWithContentDescription("Back").performClick() - // Still listed, and marked; its round has left the due lists entirely. + // Still listed, and marked. rule.awaitText("North run") rule.awaitText("ARCHIVED") + + // And its round has left the due lists entirely: back on the tab, the sprinkler's own + // schedule is listed and the group's is not. The tab's lists are read live, so the wait is + // for the archived round to leave them; the asset's own schedule says they are drawn. + rule.onNodeWithContentDescription("Back").performClick() + rule.awaitText("Nozzle clean") + rule.waitUntil(WAIT_MS) { rule.onAllNodesWithText("Head check").fetchSemanticsNodes().isEmpty() } rule.onAllNodesWithText("Head check").assertCountEquals(0) val stored = runBlocking { app.graph.groups.all().single { it.name == "North run" } } @@ -284,6 +308,7 @@ class GroupScreensTest { @Test fun theRoundsChecklistCompletesTheSelectedMemberThroughTheRatifiedAction() { aStoreWithGroups() openMaintenance() + openGroupList() rule.onNode(hasTextExactly("North run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("1 of 3 complete") @@ -327,6 +352,7 @@ class GroupScreensTest { @Test fun theSchedulesSectionOffersCreationOnlyWhileTheGroupHasAMember() { aStoreWithGroups() openMaintenance() + openGroupList() rule.onNode(hasTextExactly("North run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("Maintenance group") @@ -339,9 +365,12 @@ class GroupScreensTest { rule.awaitText("This applies to") rule.awaitText("A maintenance group") - // And the group with nobody in it offers nothing to tap. + // And the group with nobody in it offers nothing to tap: back on the group list. rule.onNodeWithContentDescription("Cancel").performClick() rule.onNodeWithContentDescription("Back").performClick() + rule.waitUntil(WAIT_MS) { + rule.onAllNodes(hasTextExactly("Emptied run") and hasClickAction()).fetchSemanticsNodes().isNotEmpty() + } rule.onNode(hasTextExactly("Emptied run") and hasClickAction()).performScrollTo().performClick() rule.awaitText("Maintenance group") // The ratified empty state stands in for the schedules it has none of. @@ -351,4 +380,8 @@ class GroupScreensTest { private fun assetIdOf(name: String): String = runBlocking { app.graph.assets.all().single { it.name == name }.id.value } + + private companion object { + const val WAIT_MS = 10_000L + } } From 82a2f096dd2f10b1a3aed0b81a4437fea32a12ef Mon Sep 17 00:00:00 2001 From: GonzRon Date: Mon, 5 Oct 2026 14:56:53 -0400 Subject: [PATCH 4/5] reminder health test: wait for the utilities heading as drawn --- .../servicetag/ui/maintenance/ReminderHealthScreenTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/ReminderHealthScreenTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/ReminderHealthScreenTest.kt index 450b177e..146755ee 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/ReminderHealthScreenTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/maintenance/ReminderHealthScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import com.loosecannon.servicetag.MainActivity +import com.loosecannon.servicetag.R import com.loosecannon.servicetag.core.model.CompletionMode import com.loosecannon.servicetag.core.model.MaintenanceSchedule import com.loosecannon.servicetag.core.model.RecurrenceUnit @@ -24,6 +25,7 @@ import com.loosecannon.servicetag.core.model.ScheduleTarget import com.loosecannon.servicetag.core.model.ServicePolicy import com.loosecannon.servicetag.core.model.TimeBasis import com.loosecannon.servicetag.core.usecase.AssetCommand +import com.loosecannon.servicetag.l10n.localized import com.loosecannon.servicetag.ui.app import com.loosecannon.servicetag.ui.awaitText import com.loosecannon.servicetag.ui.clearInstall @@ -94,8 +96,9 @@ class ReminderHealthScreenTest { // #103: Settings › Utilities › Reminder health. The row sits below the fold on a settings // screen, and a node that is in the tree but off screen takes a click that goes nowhere, // which reads exactly like a route that failed to open: scroll to it, the way a person would. + // The section heading is drawn in capitals (`SectionHeader`), so it is awaited as drawn. rule.onNodeWithContentDescription("Settings").performClick() - rule.awaitText("Utilities") + rule.awaitText(localized(R.string.settings_utilities).uppercase()) rule.onNode(hasText(REMINDER_HEALTH_TITLE) and hasClickAction()).performScrollTo().performClick() rule.awaitText(ALARM_FINDING) } From 7b47abff4710f61ec2c029d3551d47bbbef1142e Mon Sep 17 00:00:00 2001 From: GonzRon Date: Mon, 5 Oct 2026 14:57:25 -0400 Subject: [PATCH 5/5] lending back key test: put the keyboard away first, send back to the dialog window --- .../servicetag/ui/loan/LendingSectionDeviceTest.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/loan/LendingSectionDeviceTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/loan/LendingSectionDeviceTest.kt index ba220abe..48f0e9a9 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/loan/LendingSectionDeviceTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/ui/loan/LendingSectionDeviceTest.kt @@ -29,6 +29,10 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.pressBack +import androidx.test.espresso.matcher.RootMatchers +import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.ext.junit.runners.AndroidJUnit4 import com.loosecannon.servicetag.contacts.ContactRow import com.loosecannon.servicetag.contacts.ContactRowQuery @@ -313,13 +317,19 @@ class LendingSectionDeviceTest { rule.onNodeWithText(CHOOSE_FROM_CONTACTS).performClick() rule.awaitText("Sample Borrower") field(DUE_BACK).performScrollTo().performTextReplacement(due.toString()) + // Typing the date raised the soft keyboard, and the platform gives Back to a showing keyboard + // before any window: left up, it can be carried over the rationale and swallow the key under + // test (the gate's logcat: the IME re-shown over the dialog, the key spent hiding it). The + // keyboard is put away first, as a person does before reading the question. + Espresso.closeSoftKeyboard() rule.onNodeWithText(ONCE).performScrollTo().performClick() rule.onNodeWithText(SAVE_LOAN).performScrollTo().performClick() rule.awaitText(LOAN_NOTIFICATION_RATIONALE) val atTheQuestion = loans().single() // A key to the focused dialog window, as a gesture is: never the activity's own dispatcher. - Espresso.pressBack() + // Espresso injects it only once the dialog's own window holds focus. + onView(isRoot()).inRoot(RootMatchers.isDialog()).perform(pressBack()) rule.waitUntil(WAIT_MS) { done.get() == 1 } rule.waitForIdle()