From 6c17c3f04bef93fbc7bd2baae113ee2744c1a177 Mon Sep 17 00:00:00 2001 From: yilsem Date: Thu, 8 Oct 2026 04:28:34 +0300 Subject: [PATCH] test(cron): verify phone editor save/edit/run over real HTTP; clarify server timezone --- .../ui/cron/ScheduleHttpAcceptanceTest.kt | 115 ++++++++++++++++++ app/src/main/res/values-tr/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../ui/cron/ScheduleSelectionTest.kt | 2 + 4 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/cron/ScheduleHttpAcceptanceTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/ScheduleHttpAcceptanceTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/ScheduleHttpAcceptanceTest.kt new file mode 100644 index 00000000..b13c7f76 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/cron/ScheduleHttpAcceptanceTest.kt @@ -0,0 +1,115 @@ +package com.m57.hermescontrol.ui.cron + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.test.platform.app.InstrumentationRegistry +import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.ApiClient +import java.net.URL +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test + +/** Opt-in test against a fresh, isolated real cron backend, never a user server. */ +class ScheduleHttpAcceptanceTest { + @get:Rule val compose = createComposeRule() + + @Test + fun editorCreatesEditsAndScheduledJobExecutesViaHttp() { + val args = InstrumentationRegistry.getArguments() + assumeTrue(args.getString("isolatedCronAcceptance") == "true") + val context = InstrumentationRegistry.getInstrumentation().targetContext + check(BuildConfig.DEBUG && context.packageName.endsWith(".dev")) + val url = "http://127.0.0.1:18765" + AuthManager.init(context) + kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() } + val oldUrl = AuthManager.getBaseUrl() + val oldToken = AuthManager.getToken() + val store = ViewModelStore() + val owner = object : ViewModelStoreOwner { override val viewModelStore = store } + try { + AuthManager.setBaseUrl(url) + AuthManager.setToken(null) + ApiClient.rebuild() + val vm = ViewModelProvider(owner)[CronJobsViewModel::class.java] + compose.setContent { + val state by vm.uiState.collectAsState() + MaterialTheme { + if (state.editorState.isOpen) { + CronJobEditorDialog( + state = state.editorState, + onFieldChange = vm::updateEditorField, + onToggleNoAgent = vm::toggleNoAgent, + onToggleRunContinuity = vm::toggleRunContinuity, + onSetMonitorMode = vm::setMonitorMode, + onSelectBlueprint = vm::selectBlueprint, + onBlueprintFieldChange = vm::updateBlueprintValue, + onSave = vm::saveEditor, + onDismiss = vm::closeEditor, + onClearToast = vm::clearEditorToast, + ) + } + } + } + // Harmless execution fixture. Schedule is changed using the real clock below. + compose.runOnIdle { + vm.openNewJobDialog() + vm.updateEditorField("name", "Android HTTP clock acceptance") + vm.updateEditorField("script", "probe.py") + vm.updateEditorField("schedule", "59 23 * * *") + vm.toggleNoAgent() + } + compose.onNodeWithText("23:59").performScrollTo().performClick() + compose.onNodeWithText("59").performClick() + compose.onNode(hasContentDescription("55", substring = true)).performClick() + compose.onNodeWithText(context.getString(R.string.action_ok)).performClick() + compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick() + compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen } + compose.waitUntil(30_000) { vm.uiState.value.jobs.size == 1 } + val id = vm.uiState.value.jobs.single().id + compose.runOnIdle { vm.openEditJobDialog(id) } + compose.waitUntil(30_000) { !vm.uiState.value.editorState.isLoading } + compose.runOnIdle { assertEquals("55 23 * * *", vm.uiState.value.editorState.schedule) } + compose.onNodeWithText("23:55").performScrollTo() + compose.onNodeWithText(context.getString(R.string.cron_repeat_label)).performClick() + compose.onAllNodesWithText(context.getString(R.string.cron_repeat_minutely))[0].performClick() + compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick() + compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen } + val deadline = android.os.SystemClock.elapsedRealtime() + 90_000 + var evidence = JSONObject() + while (android.os.SystemClock.elapsedRealtime() < deadline) { + evidence = JSONObject(URL("$url/acceptance/evidence").readText()) + if (evidence.optString("result") == "PASS") break + android.os.SystemClock.sleep(500) + } + assertEquals(evidence.toString(), "PASS", evidence.optString("result")) + assertEquals(id, evidence.getString("job_id")) + assertEquals(1, evidence.getInt("runs")) + val requests = evidence.getJSONArray("requests") + assertEquals(2, requests.length()) + assertEquals("55 23 * * *", requests.getJSONObject(0).getJSONObject("body").getString("schedule")) + assertEquals("* * * * *", requests.getJSONObject(1).getJSONObject("body").getJSONObject("updates").getString("schedule")) + assertTrue(requests.getJSONObject(1).getString("path").endsWith(id)) + } finally { + compose.runOnIdle { store.clear() } + AuthManager.setBaseUrl(oldUrl) + AuthManager.setToken(oldToken) + ApiClient.rebuild() + } + } +} diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index a6b68ea7..a2c6269d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1950,5 +1950,5 @@ Seçilen günün olmadığı ay veya yıllarda çalışmaz. Aralık Birim - Saatler bağlı AITURK sunucusunda ayarlı saat dilimine göredir. + Saatler bağlı AITURK sunucusunun saat dilimine göredir. Sunucunun dilimi burada okunamıyor; telefonunuzdan farklı olabilir. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 243e353b..528fd44d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1950,5 +1950,5 @@ Skips months or years without the selected date. Interval Unit - Times use the time zone configured on the connected AITURK server. + Times use the connected AITURK server’s time zone. Its zone cannot be read here and may differ from your phone’s. diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt index 8a8cb402..88fb23e8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/ScheduleSelectionTest.kt @@ -15,6 +15,8 @@ class ScheduleSelectionTest { "59 23 29 2 *" to ScheduleMode.YEARLY, "0 0 1 1 *" to ScheduleMode.YEARLY, "every 120m" to ScheduleMode.INTERVAL, + "every 2h" to ScheduleMode.INTERVAL, + "every 3d" to ScheduleMode.INTERVAL, ) cases.forEach { (expression, mode) -> val parsed = parseSchedule(expression)