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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-05 22:44:13 +03:00
1 parent 80be168801
commit 442e868ce2
6 files changed
+740 -118

No files matched your search

@@ -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<CronBlueprint> = emptyList(),
)
@Serializable
data class CronBlueprint(
val key: String,
val title: String = "",
val description: String = "",
val category: String = "",
val tags: List<String> = emptyList(),
val fields: List<CronBlueprintField> = 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<String> = 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<DeliveryTarget> = 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<String, String>,
)
@@ -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<Unit>
@GET("api/cron/blueprints")
suspend fun getCronBlueprints(): Response<CronBlueprintListResponse>
@POST("api/cron/blueprints/instantiate")
suspend fun instantiateBlueprint(
@Body body: InstantiateBlueprintRequest,
): Response<CronJob>
@GET("api/cron/delivery-targets")
suspend fun getCronDeliveryTargets(): Response<DeliveryTargetsResponse>
@POST("api/gateway/start")
suspend fun startGateway(): Response<Unit>
@@ -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<CronBlueprint>,
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<String>,
deliveryTargets: List<DeliveryTarget>,
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<String>,
deliveryTargets: List<DeliveryTarget>,
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
},
)
}
}
}
}
@@ -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<CronBlueprint> = emptyList(),
val deliveryTargets: List<DeliveryTarget> = emptyList(),
val selectedBlueprintKey: String? = null,
val blueprintValues: Map<String, String> = emptyMap(),
) {
val selectedBlueprint: CronBlueprint?
get() = blueprints.firstOrNull { it.key == selectedBlueprintKey }
/** Delivery ids offered by the picker: origin, then local + connected platforms. */
val deliveryOptions: List<String>
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(
+3
View File
@@ -743,6 +743,8 @@
<string name="cron_action_add">Add cron job</string>
<string name="cron_edit_title">Edit Cron Job</string>
<string name="cron_edit_title_new">New Cron Job</string>
<string name="cron_edit_start_from">Start from</string>
<string name="cron_edit_blank_job">Blank job</string>
<string name="cron_edit_field_name">Name</string>
<string name="cron_edit_field_schedule">Schedule</string>
<string name="cron_edit_field_prompt">Prompt</string>
@@ -756,6 +758,7 @@
<string name="cron_edit_field_no_agent">No agent (script-only)</string>
<string name="cron_edit_hint_schedule">e.g. 0 9 * * * or every 2h</string>
<string name="cron_edit_hint_deliver">e.g. origin, local, all, or telegram:chat_id</string>
<string name="cron_edit_hint_time">HH:MM</string>
<string name="cron_edit_hint_skills">One per line</string>
<string name="cron_edit_action_save">Save</string>
<string name="cron_discard_title">Discard changes?</string>
@@ -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<HermesApiService>(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<InstantiateBlueprintRequest>()
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)
}
}