From a9cb89a92526aca199e01974c6482b1af4bbf7b2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:35:05 +0300 Subject: [PATCH] feat(cron): monitor-mode jobs (monitor_script/monitor_url) + blocked_config status (issue #861) (#871) - CronJob model gains optional monitor_script/monitor_url (additive-safe) - Job editor: Monitor mode section (off/script/url) with XOR source clearing; disabled while no-agent is set (backend invariant); new jobs apply monitor via create -> follow-up PUT with rollback delete on failure (the REST create model drops the fields) - Job list/details: blocked_config renders as a distinct error badge ('Blocked (config)'), no_change as informational; monitor source shown in run details Co-authored-by: M57 --- .../m57/hermescontrol/data/model/CronJob.kt | 8 + .../data/model/CronJobRequests.kt | 5 + .../ui/common/ExposedDropdownField.kt | 4 +- .../hermescontrol/ui/cron/CronJobsScreen.kt | 108 +++++++++- .../ui/cron/CronJobsViewModel.kt | 133 ++++++++++-- app/src/main/res/values/strings.xml | 9 + .../ui/cron/CronJobsViewModelTest.kt | 202 ++++++++++++++++++ 7 files changed, 448 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt index 1c20efb5..58d24cee 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt @@ -38,6 +38,10 @@ data class CronJob( val workdir: String? = null, val no_agent: Boolean? = null, val repeat: CronJobRepeat? = null, + // Monitor-mode jobs (backend cron/monitor.py): a source script/URL whose + // output is hashed each tick — the agent runs only when it changes. + val monitor_script: String? = null, + val monitor_url: String? = null, ) { val scheduleText: String get() = @@ -52,4 +56,8 @@ data class CronJob( val nextRunTime: String get() = next_run ?: next_run_at ?: "" + + /** The configured monitor source (script or URL), if any. */ + val monitorSource: String? + get() = monitor_script ?: monitor_url } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt index b5cc2e99..39903266 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJobRequests.kt @@ -18,6 +18,11 @@ data class CreateCronJobRequest( val workdir: String? = null, val no_agent: Boolean = false, val repeat: Int? = null, + // Monitor mode. NOTE: the backend REST create model does not carry these + // yet (they are silently dropped on POST) — the ViewModel applies them + // via a follow-up PUT; keep them here for forward compatibility. + val monitor_script: String? = null, + val monitor_url: String? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ExposedDropdownField.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ExposedDropdownField.kt index 63291130..dea0b22a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ExposedDropdownField.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ExposedDropdownField.kt @@ -30,17 +30,19 @@ fun ExposedDropdownField( options: List, selectedValue: String, onOptionSelected: (String) -> Unit, + enabled: Boolean = true, ) { var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( expanded = expanded, - onExpandedChange = { expanded = it }, + onExpandedChange = { if (enabled) expanded = it }, ) { OutlinedTextField( value = selectedValue.ifEmpty { options.firstOrNull() ?: "" }, onValueChange = {}, readOnly = true, + enabled = enabled, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 3233278a..4b7040de 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -169,6 +169,23 @@ fun CronJobsScreen( StatusBadgeType.NEUTRAL }, ) + // Run-status badge: blocked_config looks like a + // failure with no reason — surface it distinctly. + when (job.lastRunStatus) { + "blocked_config" -> { + StatusBadge( + text = stringResource(R.string.cron_status_blocked_config), + status = StatusBadgeType.ERROR, + ) + } + + "no_change" -> { + StatusBadge( + text = stringResource(R.string.cron_status_no_change), + status = StatusBadgeType.INFO, + ) + } + } } Spacer(modifier = Modifier.height(spacing.xs)) Text( @@ -250,6 +267,7 @@ fun CronJobsScreen( state = state.editorState, onFieldChange = { name, value -> viewModel.updateEditorField(name, value) }, onToggleNoAgent = { viewModel.toggleNoAgent() }, + onSetMonitorMode = { viewModel.setMonitorMode(it) }, onSelectBlueprint = { viewModel.selectBlueprint(it) }, onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) }, onSave = { viewModel.saveEditor() }, @@ -265,13 +283,21 @@ fun CronJobsScreen( title = { Text(job.name, style = MaterialTheme.typography.titleLarge) }, text = { Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { - RunDetailRow("Status", job.lastRunStatus.ifEmpty { "unknown" }) + RunDetailRow( + "Status", + when (job.lastRunStatus) { + "blocked_config" -> stringResource(R.string.cron_status_blocked_config) + "no_change" -> stringResource(R.string.cron_status_no_change) + else -> job.lastRunStatus.ifEmpty { "unknown" } + }, + ) job.last_run_at?.let { if (it.isNotBlank()) RunDetailRow("Last run", it) } RunDetailRow("Schedule", CronExpressionFormatter.cronToHumanReadable(job.scheduleText)) if (job.last_error != null && job.last_error.isNotBlank()) { RunDetailRow("Error", job.last_error) } job.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) } + job.monitorSource?.let { if (it.isNotBlank()) RunDetailRow("Monitor", it) } } }, confirmButton = { @@ -286,6 +312,7 @@ fun CronJobEditorDialog( state: CronJobEditorState, onFieldChange: (String, String) -> Unit, onToggleNoAgent: () -> Unit, + onSetMonitorMode: (String) -> Unit, onSelectBlueprint: (String?) -> Unit, onBlueprintFieldChange: (String, String) -> Unit, onSave: () -> Unit, @@ -295,7 +322,9 @@ fun CronJobEditorDialog( var showDiscardConfirm by remember { mutableStateOf(false) } val hasChanges = state.name.isNotEmpty() || state.schedule.isNotEmpty() || - state.prompt.isNotEmpty() || state.skills.isNotEmpty() + state.prompt.isNotEmpty() || state.skills.isNotEmpty() || + state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() || + state.monitorMode != "off" ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast) @@ -522,6 +551,17 @@ fun CronJobEditorDialog( style = MaterialTheme.typography.bodyMedium, ) } + + // Monitor mode (disabled for no-agent jobs — the + // backend rejects monitor + no_agent together) + MonitorModeSection( + monitorMode = state.monitorMode, + monitorScript = state.monitor_script, + monitorUrl = state.monitor_url, + enabled = !state.no_agent, + onFieldChange = onFieldChange, + onSetMonitorMode = onSetMonitorMode, + ) } Spacer(modifier = Modifier.height(16.dp)) @@ -555,6 +595,70 @@ fun CronJobEditorDialog( } } +@Composable +private fun MonitorModeSection( + monitorMode: String, + monitorScript: String, + monitorUrl: String, + enabled: Boolean, + onFieldChange: (String, String) -> Unit, + onSetMonitorMode: (String) -> Unit, +) { + val spacing = LocalSpacing.current + val offLabel = stringResource(R.string.cron_edit_monitor_off) + val scriptLabel = stringResource(R.string.cron_edit_monitor_script) + val urlLabel = stringResource(R.string.cron_edit_monitor_url) + + Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { + ExposedDropdownField( + label = stringResource(R.string.cron_edit_monitor_mode), + options = listOf(offLabel, scriptLabel, urlLabel), + selectedValue = + when (monitorMode) { + "script" -> scriptLabel + "url" -> urlLabel + else -> offLabel + }, + enabled = enabled, + onOptionSelected = { selected -> + when (selected) { + scriptLabel -> onSetMonitorMode("script") + urlLabel -> onSetMonitorMode("url") + else -> onSetMonitorMode("off") + } + }, + ) + when (monitorMode) { + "script" -> { + OutlinedTextField( + value = monitorScript, + onValueChange = { onFieldChange("monitor_script", it) }, + label = { Text(stringResource(R.string.cron_edit_monitor_script_field)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = enabled, + ) + } + + "url" -> { + OutlinedTextField( + value = monitorUrl, + onValueChange = { onFieldChange("monitor_url", it) }, + label = { Text(stringResource(R.string.cron_edit_monitor_url_field)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = enabled, + ) + } + } + Text( + text = stringResource(R.string.cron_edit_monitor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } +} + @Composable private fun RunDetailRow( label: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index da69d387..39f04dab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -56,6 +56,11 @@ data class CronJobEditorState( val workdir: String = "", val enabled: Boolean = true, val no_agent: Boolean = false, + // Monitor mode: one of monitor_script / monitor_url set at a time + // (mutually exclusive on the backend; incompatible with no_agent). + val monitorMode: String = "off", // "off" | "script" | "url" + val monitor_script: String = "", + val monitor_url: String = "", // Blueprint start-from state (new jobs only) val blueprints: List = emptyList(), val deliveryTargets: List = emptyList(), @@ -288,6 +293,14 @@ class CronJobsViewModel : workdir = job.workdir.orEmpty(), enabled = job.enabled ?: true, no_agent = job.no_agent ?: false, + monitorMode = + when { + !job.monitor_script.isNullOrBlank() -> "script" + !job.monitor_url.isNullOrBlank() -> "url" + else -> "off" + }, + monitor_script = job.monitor_script.orEmpty(), + monitor_url = job.monitor_url.orEmpty(), ), ) } @@ -382,23 +395,7 @@ class CronJobsViewModel : ) } } else if (editor.isNew) { - safeApiCall { - ApiClient.hermesApi.createCronJob( - CreateCronJobRequest( - name = editor.name, - schedule = editor.schedule, - prompt = editor.prompt, - deliver = editor.deliver, - skills = parseLines(editor.skills), - model = editor.model.ifBlank { null }, - provider = editor.provider.ifBlank { null }, - base_url = editor.base_url.ifBlank { null }, - script = editor.script.ifBlank { null }, - workdir = editor.workdir.ifBlank { null }, - no_agent = editor.no_agent, - ), - ) - } + createNewJobWithMonitor(editor) } else { val updates = mutableMapOf() if (editor.name.isNotEmpty()) updates["name"] = editor.name @@ -412,6 +409,10 @@ class CronJobsViewModel : updates["script"] = editor.script.ifBlank { null } updates["workdir"] = editor.workdir.ifBlank { null } updates["no_agent"] = editor.no_agent + // Blank clears the monitor source on the backend (empty + // string or null both mean "clear the field"). + updates["monitor_script"] = editor.monitor_script.ifBlank { null } + updates["monitor_url"] = editor.monitor_url.ifBlank { null } safeApiCall { ApiClient.hermesApi.updateCronJob( editor.jobId ?: "", @@ -445,6 +446,56 @@ class CronJobsViewModel : _uiState.update { it.copy(editorState = it.editorState.copy(toastMessage = null)) } } + /** + * Create a blank job, then apply monitor mode via a follow-up PUT. + * + * The backend REST create model (CronJobCreate) does not carry + * monitor_script/monitor_url yet — a POST alone would silently drop them. + * The PUT updates path accepts them (verified against web_server.py + * _update_cron_job_sync / cron.jobs.update_job), so create first, then + * apply. If the monitor update fails, the just-created job is rolled back + * (deleted) — a half-configured job is worse than no job. + */ + private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult { + val createdResult = + safeApiCall { + ApiClient.hermesApi.createCronJob( + CreateCronJobRequest( + name = editor.name, + schedule = editor.schedule, + prompt = editor.prompt, + deliver = editor.deliver, + skills = parseLines(editor.skills), + model = editor.model.ifBlank { null }, + provider = editor.provider.ifBlank { null }, + base_url = editor.base_url.ifBlank { null }, + script = editor.script.ifBlank { null }, + workdir = editor.workdir.ifBlank { null }, + no_agent = editor.no_agent, + monitor_script = editor.monitor_script.ifBlank { null }, + monitor_url = editor.monitor_url.ifBlank { null }, + ), + ) + } + val monitorUpdates = editorMonitorUpdates(editor) + if (createdResult !is NetworkResult.Success || monitorUpdates.isEmpty()) { + return createdResult + } + val applyResult = + safeApiCall { + ApiClient.hermesApi.updateCronJob( + createdResult.data.id, + UpdateCronJobRequest(updates = monitorUpdates.mapValues { it.value.toJsonElement() }), + ) + } + if (applyResult is NetworkResult.Success) { + return createdResult + } + // Roll back: the job exists but is missing its monitor source. + safeApiCall { ApiClient.hermesApi.deleteCronJob(createdResult.data.id) } + return applyResult + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } @@ -486,12 +537,58 @@ class CronJobsViewModel : "base_url" -> copy(base_url = value) "script" -> copy(script = value) "workdir" -> copy(workdir = value) + // Monitor fields are mutually exclusive: entering one clears the other. + "monitor_script" -> copy(monitor_script = value, monitor_url = if (value.isNotBlank()) "" else monitor_url) + "monitor_url" -> copy(monitor_url = value, monitor_script = if (value.isNotBlank()) "" else monitor_script) else -> this } fun toggleNoAgent() { _uiState.update { - it.copy(editorState = it.editorState.copy(no_agent = !it.editorState.no_agent)) + it.copy( + editorState = + it.editorState.copy( + no_agent = !it.editorState.no_agent, + // Monitor mode is incompatible with no_agent on the backend + // (cron/jobs.py _validate_job_mode_invariants) — clear it so + // a stale value can't 400 the save. Clear when turning + // no_agent ON (old value false). + monitorMode = if (!it.editorState.no_agent) "off" else it.editorState.monitorMode, + monitor_script = if (!it.editorState.no_agent) "" else it.editorState.monitor_script, + monitor_url = if (!it.editorState.no_agent) "" else it.editorState.monitor_url, + ), + ) + } + } + + /** + * Switch the editor's monitor mode. Selecting a different source clears + * the other one (backend invariant: monitor_script XOR monitor_url); + * selecting "off" clears both. + */ + fun setMonitorMode(mode: String) { + _uiState.update { state -> + val editor = state.editorState + state.copy( + editorState = + editor.copy( + monitorMode = mode, + monitor_script = if (mode != "script") "" else editor.monitor_script, + monitor_url = if (mode != "url") "" else editor.monitor_url, + ), + ) + } + } + + /** Monitor fields configured in the editor (at most one is non-blank). */ + private fun editorMonitorUpdates(editor: CronJobEditorState): Map { + return buildMap { + if (editor.monitor_script.isNotBlank()) { + put("monitor_script", editor.monitor_script.trim()) + } + if (editor.monitor_url.isNotBlank()) { + put("monitor_url", editor.monitor_url.trim()) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ec6ab22..a5a39df8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -795,6 +795,15 @@ Script Work Directory No agent (script-only) + Monitor mode + Off + Script + URL + Monitor script + Monitor URL + Agent runs only when the source output changes + Blocked (config) + No change e.g. 0 9 * * * or every 2h e.g. origin, local, all, or telegram:chat_id HH:MM diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index 24fda1ad..2eb42c2a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest +import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService import io.mockk.coEvery @@ -22,6 +23,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After @@ -223,4 +225,204 @@ class CronJobsViewModelTest { coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) } assertFalse(vm.uiState.value.editorState.isOpen) } + + @Test + fun `setMonitorMode switching source clears the other`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + + vm.setMonitorMode("script") + vm.updateEditorField("monitor_script", "check.sh") + + var editor = vm.uiState.value.editorState + assertEquals("script", editor.monitorMode) + assertEquals("check.sh", editor.monitor_script) + + vm.setMonitorMode("url") + + editor = vm.uiState.value.editorState + assertEquals("url", editor.monitorMode) + assertEquals("", editor.monitor_script) + } + + @Test + fun `setMonitorMode off clears both sources`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.setMonitorMode("url") + vm.updateEditorField("monitor_url", "https://example.com/status") + + vm.setMonitorMode("off") + + val editor = vm.uiState.value.editorState + assertEquals("off", editor.monitorMode) + assertEquals("", editor.monitor_script) + assertEquals("", editor.monitor_url) + } + + @Test + fun `toggleNoAgent clears monitor fields`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.setMonitorMode("script") + vm.updateEditorField("monitor_script", "check.sh") + + vm.toggleNoAgent() + + val editor = vm.uiState.value.editorState + assertTrue(editor.no_agent) + assertEquals("off", editor.monitorMode) + assertEquals("", editor.monitor_script) + assertEquals("", editor.monitor_url) + } + + @Test + fun `new job with monitor mode applies it via follow-up update`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.updateEditorField("name", "Watch job") + vm.updateEditorField("schedule", "every 10m") + vm.updateEditorField("prompt", "Summarize changes") + vm.setMonitorMode("script") + vm.updateEditorField("monitor_script", "check.sh") + val created = CronJob(id = "j3", name = "Watch job") + coEvery { mockApi.createCronJob(any()) } returns Response.success(created) + coEvery { mockApi.updateCronJob(eq("j3"), any()) } returns Response.success(created) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.saveEditor() + settle() + + coVerify(exactly = 1) { mockApi.createCronJob(any()) } + val updateSlot = slot() + coVerify { mockApi.updateCronJob(eq("j3"), capture(updateSlot)) } + assertEquals(JsonPrimitive("check.sh"), updateSlot.captured.updates["monitor_script"]) + assertFalse(vm.uiState.value.editorState.isOpen) + } + + @Test + fun `new job without monitor skips follow-up update`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.updateEditorField("name", "Plain job") + vm.updateEditorField("schedule", "every 10m") + val created = CronJob(id = "j4", name = "Plain job") + coEvery { mockApi.createCronJob(any()) } returns Response.success(created) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.saveEditor() + settle() + + coVerify(exactly = 1) { mockApi.createCronJob(any()) } + coVerify(exactly = 0) { mockApi.updateCronJob(any(), any()) } + assertFalse(vm.uiState.value.editorState.isOpen) + } + + @Test + fun `monitor update failure rolls back the created job`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.updateEditorField("name", "Watch job") + vm.updateEditorField("schedule", "every 10m") + vm.setMonitorMode("url") + vm.updateEditorField("monitor_url", "https://example.com/status") + val created = CronJob(id = "j5", name = "Watch job") + coEvery { mockApi.createCronJob(any()) } returns Response.success(created) + coEvery { mockApi.updateCronJob(eq("j5"), any()) } returns + Response.error( + 400, + """{"detail":"monitor_script and monitor_url are mutually exclusive"}""".toResponseBody(), + ) + coEvery { mockApi.deleteCronJob(eq("j5")) } returns Response.success(Unit) + + vm.saveEditor() + settle() + + coVerify { mockApi.deleteCronJob("j5") } + assertTrue(vm.uiState.value.editorState.isOpen) + assertTrue(vm.uiState.value.editorState.toastMessage.orEmpty().contains("Failed to save")) + } + + @Test + fun `openEditJobDialog prefills monitor fields and mode`() { + val vm = createViewModel() + coEvery { mockApi.getCronJob("j6") } returns + Response.success( + CronJob( + id = "j6", + name = "Monitored", + schedule = JsonPrimitive("every 10m"), + monitor_script = "check.sh", + ), + ) + + vm.openEditJobDialog("j6") + settle() + + val editor = vm.uiState.value.editorState + assertEquals("script", editor.monitorMode) + assertEquals("check.sh", editor.monitor_script) + assertEquals("", editor.monitor_url) + } + + @Test + fun `edit save sends monitor fields in updates`() { + val vm = createViewModel() + coEvery { mockApi.getCronJob("j7") } returns + Response.success( + CronJob( + id = "j7", + name = "Monitored", + schedule = JsonPrimitive("every 10m"), + monitor_url = "https://example.com/status", + ), + ) + coEvery { mockApi.updateCronJob(eq("j7"), any()) } returns + Response.success(CronJob(id = "j7", name = "Monitored")) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.openEditJobDialog("j7") + settle() + vm.updateEditorField("name", "Renamed") + vm.saveEditor() + settle() + + val updateSlot = slot() + coVerify { mockApi.updateCronJob(eq("j7"), capture(updateSlot)) } + assertEquals(JsonPrimitive("https://example.com/status"), updateSlot.captured.updates["monitor_url"]) + assertFalse(vm.uiState.value.editorState.isOpen) + } + + @Test + fun `edit save clears monitor when field emptied`() { + val vm = createViewModel() + coEvery { mockApi.getCronJob("j8") } returns + Response.success( + CronJob( + id = "j8", + name = "Monitored", + schedule = JsonPrimitive("every 10m"), + monitor_script = "check.sh", + ), + ) + coEvery { mockApi.updateCronJob(eq("j8"), any()) } returns + Response.success(CronJob(id = "j8", name = "Monitored")) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.openEditJobDialog("j8") + settle() + vm.updateEditorField("monitor_script", "") + vm.saveEditor() + settle() + + val updateSlot = slot() + coVerify { mockApi.updateCronJob(eq("j8"), capture(updateSlot)) } + assertEquals(JsonNull, updateSlot.captured.updates["monitor_script"]) + } }