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)