From 442e868ce2fe3a36307f1a5669ff15c0e9c16e73 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:44:13 +0300 Subject: [PATCH] feat(cron): blueprint start-from + delivery target picker in job editor (issue #780) (#822) - New-job dialog gains a Start-from dropdown: pick a catalog blueprint, fill its typed slots (time/enum/weekdays/text), save instantiates it via POST /api/cron/blueprints/instantiate - Delivery field is now an editable picker fed by GET /api/cron/delivery-targets (origin + local + connected platforms) - Blueprints and targets fetched on editor open; 422 slot errors surface the backend detail Co-authored-by: M57 --- .../hermescontrol/data/model/CronBlueprint.kt | 58 +++ .../data/remote/HermesApiService.kt | 14 + .../hermescontrol/ui/cron/CronJobsScreen.kt | 443 +++++++++++++----- .../ui/cron/CronJobsViewModel.kt | 114 ++++- app/src/main/res/values/strings.xml | 3 + .../ui/cron/CronJobsViewModelTest.kt | 226 +++++++++ 6 files changed, 740 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/CronBlueprint.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronBlueprint.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronBlueprint.kt new file mode 100644 index 00000000..6bd1a322 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronBlueprint.kt @@ -0,0 +1,58 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +@Serializable +data class CronBlueprintListResponse( + val blueprints: List = emptyList(), +) + +@Serializable +data class CronBlueprint( + val key: String, + val title: String = "", + val description: String = "", + val category: String = "", + val tags: List = emptyList(), + val fields: List = emptyList(), + val schedule: String = "", + val scheduleHuman: String = "", + val command: String = "", + val appUrl: String = "", +) + +@Serializable +data class CronBlueprintField( + val name: String, + val type: String = "text", + val label: String = "", + val default: JsonElement? = null, + val options: List = emptyList(), + val optional: Boolean = false, + val strict: Boolean = true, + val help: String = "", +) { + val defaultText: String + get() = (default as? JsonPrimitive)?.content.orEmpty() +} + +@Serializable +data class DeliveryTargetsResponse( + val targets: List = emptyList(), +) + +@Serializable +data class DeliveryTarget( + val id: String, + val name: String = "", + val home_target_set: Boolean = false, + val home_env_var: String? = null, +) + +@Serializable +data class InstantiateBlueprintRequest( + val blueprint: String, + val values: Map, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 2307845b..4d86df19 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -19,10 +19,12 @@ import com.m57.hermescontrol.data.model.CreateProfileRequest import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse +import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse +import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarDeleteRequest @@ -31,6 +33,7 @@ import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse +import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.LearningGraphResponse @@ -308,6 +311,17 @@ interface HermesApiService { @Path("id") id: String, ): Response + @GET("api/cron/blueprints") + suspend fun getCronBlueprints(): Response + + @POST("api/cron/blueprints/instantiate") + suspend fun instantiateBlueprint( + @Body body: InstantiateBlueprintRequest, + ): Response + + @GET("api/cron/delivery-targets") + suspend fun getCronDeliveryTargets(): Response + @POST("api/gateway/start") suspend fun startGateway(): Response 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 5e210f1c..3233278a 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 @@ -27,6 +27,11 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -50,10 +55,14 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CronBlueprint +import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.ExposedDropdownField import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon @@ -241,6 +250,8 @@ fun CronJobsScreen( state = state.editorState, onFieldChange = { name, value -> viewModel.updateEditorField(name, value) }, onToggleNoAgent = { viewModel.toggleNoAgent() }, + onSelectBlueprint = { viewModel.selectBlueprint(it) }, + onBlueprintFieldChange = { name, value -> viewModel.updateBlueprintValue(name, value) }, onSave = { viewModel.saveEditor() }, onDismiss = { viewModel.closeEditor() }, onClearToast = { viewModel.clearEditorToast() }, @@ -275,6 +286,8 @@ fun CronJobEditorDialog( state: CronJobEditorState, onFieldChange: (String, String) -> Unit, onToggleNoAgent: () -> Unit, + onSelectBlueprint: (String?) -> Unit, + onBlueprintFieldChange: (String, String) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit, onClearToast: () -> Unit, @@ -360,127 +373,155 @@ fun CronJobEditorDialog( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // Name - OutlinedTextField( - value = state.name, - onValueChange = { onFieldChange("name", it) }, - label = { Text(stringResource(R.string.cron_edit_field_name)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) + val blueprint = state.selectedBlueprint - // Schedule (required) - OutlinedTextField( - value = state.schedule, - onValueChange = { onFieldChange("schedule", it) }, - label = { Text(stringResource(R.string.cron_edit_field_schedule)) }, - placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // Prompt - OutlinedTextField( - value = state.prompt, - onValueChange = { onFieldChange("prompt", it) }, - label = { Text(stringResource(R.string.cron_edit_field_prompt)) }, - modifier = - Modifier - .fillMaxWidth() - .height(120.dp), - textStyle = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - ) - - // Delivery - OutlinedTextField( - value = state.deliver, - onValueChange = { onFieldChange("deliver", it) }, - label = { Text(stringResource(R.string.cron_edit_field_deliver)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - supportingText = { + // Start-from blueprint picker (new jobs only; hidden when the + // catalog failed to load) + if (state.isNew && state.blueprints.isNotEmpty()) { + BlueprintStartFromDropdown( + blueprints = state.blueprints, + selectedKey = state.selectedBlueprintKey, + onSelect = onSelectBlueprint, + ) + blueprint?.let { Text( - stringResource(R.string.cron_edit_hint_deliver), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + text = it.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - }, - ) + } + } - // Skills - OutlinedTextField( - value = state.skills, - onValueChange = { onFieldChange("skills", it) }, - label = { Text(stringResource(R.string.cron_edit_field_skills)) }, - placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) }, - modifier = - Modifier - .fillMaxWidth() - .height(100.dp), - textStyle = MaterialTheme.typography.bodySmall, - ) - - // Model - OutlinedTextField( - value = state.model, - onValueChange = { onFieldChange("model", it) }, - label = { Text(stringResource(R.string.cron_edit_field_model)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // Provider - OutlinedTextField( - value = state.provider, - onValueChange = { onFieldChange("provider", it) }, - label = { Text(stringResource(R.string.cron_edit_field_provider)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // Base URL - OutlinedTextField( - value = state.base_url, - onValueChange = { onFieldChange("base_url", it) }, - label = { Text(stringResource(R.string.cron_edit_field_base_url)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // Script path - OutlinedTextField( - value = state.script, - onValueChange = { onFieldChange("script", it) }, - label = { Text(stringResource(R.string.cron_edit_field_script)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // Work directory - OutlinedTextField( - value = state.workdir, - onValueChange = { onFieldChange("workdir", it) }, - label = { Text(stringResource(R.string.cron_edit_field_workdir)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - - // No Agent toggle - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = state.no_agent, - onCheckedChange = { onToggleNoAgent() }, + if (blueprint != null) { + // Blueprint mode: typed slots only — the backend renders the + // prompt and schedule from the filled values. + blueprint.fields.forEach { field -> + BlueprintSlotField( + field = field, + value = state.blueprintValues[field.name].orEmpty(), + deliveryOptions = state.deliveryOptions, + deliveryTargets = state.deliveryTargets, + onValueChange = { onBlueprintFieldChange(field.name, it) }, + ) + } + } else { + // Name + OutlinedTextField( + value = state.name, + onValueChange = { onFieldChange("name", it) }, + label = { Text(stringResource(R.string.cron_edit_field_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.cron_edit_field_no_agent), - style = MaterialTheme.typography.bodyMedium, + + // Schedule (required) + OutlinedTextField( + value = state.schedule, + onValueChange = { onFieldChange("schedule", it) }, + label = { Text(stringResource(R.string.cron_edit_field_schedule)) }, + placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, ) + + // Prompt + OutlinedTextField( + value = state.prompt, + onValueChange = { onFieldChange("prompt", it) }, + label = { Text(stringResource(R.string.cron_edit_field_prompt)) }, + modifier = + Modifier + .fillMaxWidth() + .height(120.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + // Delivery + DeliverField( + label = stringResource(R.string.cron_edit_field_deliver), + value = state.deliver, + options = state.deliveryOptions, + deliveryTargets = state.deliveryTargets, + onValueChange = { onFieldChange("deliver", it) }, + hint = stringResource(R.string.cron_edit_hint_deliver), + ) + + // Skills + OutlinedTextField( + value = state.skills, + onValueChange = { onFieldChange("skills", it) }, + label = { Text(stringResource(R.string.cron_edit_field_skills)) }, + placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) }, + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + textStyle = MaterialTheme.typography.bodySmall, + ) + + // Model + OutlinedTextField( + value = state.model, + onValueChange = { onFieldChange("model", it) }, + label = { Text(stringResource(R.string.cron_edit_field_model)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Provider + OutlinedTextField( + value = state.provider, + onValueChange = { onFieldChange("provider", it) }, + label = { Text(stringResource(R.string.cron_edit_field_provider)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Base URL + OutlinedTextField( + value = state.base_url, + onValueChange = { onFieldChange("base_url", it) }, + label = { Text(stringResource(R.string.cron_edit_field_base_url)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Script path + OutlinedTextField( + value = state.script, + onValueChange = { onFieldChange("script", it) }, + label = { Text(stringResource(R.string.cron_edit_field_script)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // Work directory + OutlinedTextField( + value = state.workdir, + onValueChange = { onFieldChange("workdir", it) }, + label = { Text(stringResource(R.string.cron_edit_field_workdir)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + // No Agent toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = state.no_agent, + onCheckedChange = { onToggleNoAgent() }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.cron_edit_field_no_agent), + style = MaterialTheme.typography.bodyMedium, + ) + } } Spacer(modifier = Modifier.height(16.dp)) @@ -537,3 +578,175 @@ private fun RunDetailRow( ) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BlueprintStartFromDropdown( + blueprints: List, + selectedKey: String?, + onSelect: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedTitle = + blueprints.firstOrNull { it.key == selectedKey }?.title + ?: stringResource(R.string.cron_edit_blank_job) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedTitle, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.cron_edit_start_from)) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + singleLine = true, + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.cron_edit_blank_job)) }, + onClick = { + onSelect(null) + expanded = false + }, + ) + blueprints.forEach { blueprint -> + DropdownMenuItem( + text = { Text(blueprint.title) }, + onClick = { + onSelect(blueprint.key) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun BlueprintSlotField( + field: CronBlueprintField, + value: String, + deliveryOptions: List, + deliveryTargets: List, + onValueChange: (String) -> Unit, +) { + when { + field.name == "deliver" -> { + DeliverField( + label = field.label, + value = value, + options = deliveryOptions, + deliveryTargets = deliveryTargets, + onValueChange = onValueChange, + ) + } + + field.type == "enum" || field.type == "weekdays" -> { + ExposedDropdownField( + label = field.label, + options = field.options, + selectedValue = value, + onOptionSelected = onValueChange, + ) + } + + else -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.label) }, + placeholder = + if (field.type == "time") { + { Text(stringResource(R.string.cron_edit_hint_time)) } + } else { + null + }, + supportingText = + if (field.help.isNotBlank()) { + { + Text( + text = field.help, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } else { + null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + } +} + +/** + * Delivery target picker: an editable dropdown fed by the backend's + * delivery-targets endpoint (origin + local + connected platforms). Picking + * fills the field; typing keeps the legacy free-text behaviour (e.g. + * `telegram:chat_id`). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeliverField( + label: String, + value: String, + options: List, + deliveryTargets: List, + onValueChange: (String) -> Unit, + hint: String? = null, +) { + var expanded by remember { mutableStateOf(false) } + val displayNames = + deliveryTargets.associate { it.id to it.name.ifBlank { it.id } } + ("origin" to "origin") + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = + Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + supportingText = + hint?.let { + { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + singleLine = true, + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { id -> + DropdownMenuItem( + text = { Text(displayNames[id] ?: id) }, + onClick = { + onValueChange(id) + expanded = false + }, + ) + } + } + } +} 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 1e93c659..d45d6fb8 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 @@ -3,7 +3,10 @@ package com.m57.hermescontrol.ui.cron import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.CreateCronJobRequest +import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronJob +import com.m57.hermescontrol.data.model.DeliveryTarget +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.NetworkResult @@ -51,7 +54,26 @@ data class CronJobEditorState( val workdir: String = "", val enabled: Boolean = true, val no_agent: Boolean = false, -) + // Blueprint start-from state (new jobs only) + val blueprints: List = emptyList(), + val deliveryTargets: List = emptyList(), + val selectedBlueprintKey: String? = null, + val blueprintValues: Map = emptyMap(), +) { + val selectedBlueprint: CronBlueprint? + get() = blueprints.firstOrNull { it.key == selectedBlueprintKey } + + /** Delivery ids offered by the picker: origin, then local + connected platforms. */ + val deliveryOptions: List + get() = + buildList { + add("origin") + addAll(deliveryTargets.map { it.id }) + if (deliveryTargets.none { it.id == "local" }) { + add("local") + } + } +} class CronJobsViewModel : ViewModel(), @@ -169,6 +191,50 @@ class CronJobsViewModel : ), ) } + loadEditorBlueprints() + loadDeliveryTargets() + } + + fun loadEditorBlueprints() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getCronBlueprints() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(editorState = it.editorState.copy(blueprints = result.data.blueprints)) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + editorState = + it.editorState.copy( + toastMessage = "Failed to load blueprints: ${result.error.message}", + ), + ) + } + } + } + } + } + + fun loadDeliveryTargets() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getCronDeliveryTargets() } + } + if (result is NetworkResult.Success) { + _uiState.update { + it.copy(editorState = it.editorState.copy(deliveryTargets = result.data.targets)) + } + } + // Failure: editor keeps working; the deliver picker falls back to origin/local. + } } fun openEditJobDialog(id: String) { @@ -230,12 +296,45 @@ class CronJobsViewModel : } } } + loadDeliveryTargets() } fun closeEditor() { _uiState.update { it.copy(editorState = CronJobEditorState()) } } + /** Pick a start-from blueprint (null = blank job); seeds its slot defaults. */ + fun selectBlueprint(key: String?) { + _uiState.update { state -> + val editor = state.editorState + val blueprint = key?.let { k -> editor.blueprints.firstOrNull { it.key == k } } + val values = blueprint?.fields?.associate { it.name to it.defaultText }.orEmpty() + state.copy( + editorState = + editor.copy( + selectedBlueprintKey = key, + blueprintValues = values, + toastMessage = null, + ), + ) + } + } + + fun updateBlueprintValue( + name: String, + value: String, + ) { + _uiState.update { state -> + state.copy( + editorState = + state.editorState.copy( + blueprintValues = state.editorState.blueprintValues + (name to value), + toastMessage = null, + ), + ) + } + } + fun updateEditorField( name: String, value: String, @@ -252,7 +351,7 @@ class CronJobsViewModel : fun saveEditor() { val editor = _uiState.value.editorState - if (editor.schedule.isBlank()) { + if (editor.selectedBlueprintKey == null && editor.schedule.isBlank()) { _uiState.update { it.copy(editorState = editor.copy(toastMessage = "Schedule is required")) } return } @@ -261,7 +360,16 @@ class CronJobsViewModel : viewModelScope.launch { val result = withContext(Dispatchers.IO) { - if (editor.isNew) { + if (editor.isNew && editor.selectedBlueprintKey != null) { + safeApiCall { + ApiClient.hermesApi.instantiateBlueprint( + InstantiateBlueprintRequest( + blueprint = editor.selectedBlueprintKey, + values = editor.blueprintValues, + ), + ) + } + } else if (editor.isNew) { safeApiCall { ApiClient.hermesApi.createCronJob( CreateCronJobRequest( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09b038c9..c7c006bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -743,6 +743,8 @@ Add cron job Edit Cron Job New Cron Job + Start from + Blank job Name Schedule Prompt @@ -756,6 +758,7 @@ No agent (script-only) e.g. 0 9 * * * or every 2h e.g. origin, local, all, or telegram:chat_id + HH:MM One per line Save Discard changes? 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 new file mode 100644 index 00000000..24fda1ad --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -0,0 +1,226 @@ +package com.m57.hermescontrol.ui.cron + +import com.m57.hermescontrol.data.model.CronBlueprint +import com.m57.hermescontrol.data.model.CronBlueprintField +import com.m57.hermescontrol.data.model.CronBlueprintListResponse +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.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class CronJobsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApi = mockk(relaxed = true) + + private val morningBrief = + CronBlueprint( + key = "morning-brief", + title = "Morning briefing", + description = "A short daily briefing.", + fields = + listOf( + CronBlueprintField( + name = "time", + type = "time", + label = "What time?", + default = JsonPrimitive("08:00"), + help = "24h local time, e.g. 08:00", + ), + CronBlueprintField( + name = "deliver", + type = "enum", + label = "Where to deliver?", + default = JsonPrimitive("origin"), + options = listOf("origin", "local", "telegram"), + strict = false, + ), + ), + ) + + private fun createViewModel(): CronJobsViewModel { + val vm = CronJobsViewModel() + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + /** + * Pump the test scheduler while letting the real Dispatchers.IO hops + * (safeLaunchLoad / withContext(IO)) land their resumptions. + */ + private fun settle() { + repeat(20) { + testDispatcher.scheduler.advanceUntilIdle() + Thread.sleep(10) + } + testDispatcher.scheduler.advanceUntilIdle() + } + + private fun stubEditorData() { + coEvery { mockApi.getCronBlueprints() } returns + Response.success(CronBlueprintListResponse(blueprints = listOf(morningBrief))) + coEvery { mockApi.getCronDeliveryTargets() } returns + Response.success( + DeliveryTargetsResponse( + targets = + listOf( + DeliveryTarget(id = "local", name = "Local (save only)", home_target_set = true), + DeliveryTarget(id = "telegram", name = "Telegram", home_target_set = true), + ), + ), + ) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + every { ApiClient.hermesApi } returns mockApi + stubEditorData() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `openNewJobDialog loads blueprints and delivery targets`() { + val vm = createViewModel() + + vm.openNewJobDialog() + settle() + + val editor = vm.uiState.value.editorState + assertTrue(editor.isOpen) + assertTrue(editor.isNew) + assertEquals(listOf("morning-brief"), editor.blueprints.map { it.key }) + assertEquals(listOf("local", "telegram"), editor.deliveryTargets.map { it.id }) + // origin first, then local + connected platforms + assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions) + } + + @Test + fun `selectBlueprint seeds slot defaults`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + + vm.selectBlueprint("morning-brief") + + val editor = vm.uiState.value.editorState + assertEquals("morning-brief", editor.selectedBlueprintKey) + assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), editor.blueprintValues) + assertEquals("Morning briefing", editor.selectedBlueprint?.title) + } + + @Test + fun `selectBlueprint null returns to blank job`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + + vm.selectBlueprint("morning-brief") + vm.selectBlueprint(null) + + val editor = vm.uiState.value.editorState + assertNull(editor.selectedBlueprintKey) + assertTrue(editor.blueprintValues.isEmpty()) + } + + @Test + fun `updateBlueprintValue overrides a seeded slot`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.selectBlueprint("morning-brief") + + vm.updateBlueprintValue("time", "09:30") + + assertEquals("09:30", vm.uiState.value.editorState.blueprintValues["time"]) + } + + @Test + fun `saveEditor with blueprint instantiated`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.selectBlueprint("morning-brief") + coEvery { mockApi.instantiateBlueprint(any()) } returns + Response.success(CronJob(id = "j1", name = "Morning briefing")) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.saveEditor() + settle() + + val requestSlot = slot() + coVerify { mockApi.instantiateBlueprint(capture(requestSlot)) } + assertEquals("morning-brief", requestSlot.captured.blueprint) + assertEquals(mapOf("time" to "08:00", "deliver" to "origin"), requestSlot.captured.values) + assertFalse(vm.uiState.value.editorState.isOpen) + } + + @Test + fun `saveEditor blueprint validation error surfaces backend detail`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.selectBlueprint("morning-brief") + coEvery { mockApi.instantiateBlueprint(any()) } returns + Response.error(422, """{"detail":"invalid time '25:00' - use HH:MM (24h)"}""".toResponseBody()) + + vm.saveEditor() + settle() + + val toast = vm.uiState.value.editorState.toastMessage.orEmpty() + assertTrue(toast, toast.contains("invalid time '25:00'")) + assertFalse(vm.uiState.value.editorState.isSaving) + } + + @Test + fun `saveEditor blank job still creates via createCronJob`() { + val vm = createViewModel() + vm.openNewJobDialog() + settle() + vm.updateEditorField("name", "Test job") + vm.updateEditorField("schedule", "0 9 * * *") + vm.updateEditorField("prompt", "Say hi") + coEvery { mockApi.createCronJob(any()) } returns + Response.success(CronJob(id = "j2", name = "Test job")) + coEvery { mockApi.getCronJobs() } returns Response.success(emptyList()) + + vm.saveEditor() + settle() + + coVerify { mockApi.createCronJob(any()) } + coVerify(exactly = 0) { mockApi.instantiateBlueprint(any()) } + assertFalse(vm.uiState.value.editorState.isOpen) + } +}