diff --git a/static/panels.js b/static/panels.js
index a1ecd517..4a918aa2 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -467,12 +467,12 @@ function _syncCronScheduleWarning() {
}
const CRON_SCHEDULE_PRESETS = [
- { id: 'custom', value: '', label: 'cron_schedule_preset_custom', fallback: 'Custom' },
- { id: 'hourly', value: 'every 1h', label: 'cron_schedule_preset_hourly', fallback: 'Hourly' },
- { id: 'daily', value: '0 9 * * *', label: 'cron_schedule_preset_daily', fallback: 'Daily' },
- { id: 'weekdays', value: '0 9 * * 1-5', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays' },
- { id: 'weekly', value: '0 9 * * 1', label: 'cron_schedule_preset_weekly', fallback: 'Weekly' },
- { id: 'monthly', value: '0 9 1 * *', label: 'cron_schedule_preset_monthly', fallback: 'Monthly' },
+ { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] },
+ { id: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } },
+ { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } },
+ { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } },
+ { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['hour', 'minute', 'weekday'], defaults: { hour: 9, minute: 0, weekday: 1 } },
+ { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['hour', 'minute', 'monthDay'], defaults: { hour: 9, minute: 0, monthDay: 1 } },
];
function _cronSchedulePresetOptionHtml() {
@@ -481,18 +481,159 @@ function _cronSchedulePresetOptionHtml() {
.join('');
}
-function _cronSchedulePresetIdForValue(value) {
+function _cronSchedulePresetForId(presetId) {
+ return CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId) || null;
+}
+
+function _cronSchedulePresetControlIds() {
+ return {
+ hour: 'cronFormScheduleHour',
+ minute: 'cronFormScheduleMinute',
+ weekday: 'cronFormScheduleWeekday',
+ monthDay: 'cronFormScheduleMonthDay',
+ };
+}
+
+function _cronSchedulePresetFieldId(field) {
+ const ids = _cronSchedulePresetControlIds();
+ return ids[field] || '';
+}
+
+function _cronSchedulePresetFieldEl(field) {
+ const id = _cronSchedulePresetFieldId(field);
+ return id ? $(id) : null;
+}
+
+function _cronSchedulePresetBounds(field) {
+ if (field === 'hour') return { min: 0, max: 23 };
+ if (field === 'minute') return { min: 0, max: 59 };
+ if (field === 'weekday') return { min: 0, max: 7 };
+ if (field === 'monthDay') return { min: 1, max: 31 };
+ return { min: 0, max: 999 };
+}
+
+function _cronSchedulePresetNormalizeValue(field, value, fallback) {
+ const bounds = _cronSchedulePresetBounds(field);
+ const parsed = parseInt(String(value ?? '').trim(), 10);
+ const fallbackParsed = parseInt(String(fallback ?? bounds.min).trim(), 10);
+ const safeFallback = Number.isFinite(fallbackParsed) ? fallbackParsed : bounds.min;
+ const n = Number.isFinite(parsed) ? parsed : safeFallback;
+ return String(Math.min(bounds.max, Math.max(bounds.min, n)));
+}
+
+function _cronSchedulePresetValueForField(field, fallback) {
+ const el = _cronSchedulePresetFieldEl(field);
+ const raw = el ? String(el.value || '').trim() : '';
+ return _cronSchedulePresetNormalizeValue(field, raw, fallback);
+}
+
+function _cronSchedulePresetRawFieldInBounds(field, value) {
+ const raw = String(value || '').trim();
+ if (!/^\d+$/.test(raw)) return false;
+ const bounds = _cronSchedulePresetBounds(field);
+ const parsed = parseInt(raw, 10);
+ return Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max;
+}
+
+function _cronSchedulePresetApplyValues(values) {
+ ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => {
+ const el = _cronSchedulePresetFieldEl(field);
+ if (!el || values[field] == null) return;
+ el.value = _cronSchedulePresetNormalizeValue(field, values[field], values[field]);
+ });
+}
+
+function _cronSchedulePresetSyncVisibility(presetId) {
+ const wrapper = $('cronFormSchedulePresetParams');
+ const preset = _cronSchedulePresetForId(presetId);
+ const showFields = preset && Array.isArray(preset.fields) ? preset.fields : [];
+ if (wrapper) wrapper.style.display = presetId === 'custom' ? 'none' : '';
+ ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => {
+ const fieldWrap = $(`cronFormSchedule${field === 'monthDay' ? 'MonthDay' : field.charAt(0).toUpperCase() + field.slice(1)}Field`);
+ if (fieldWrap) fieldWrap.style.display = showFields.includes(field) ? '' : 'none';
+ });
+}
+
+function _cronSchedulePresetValuesForSelection(presetId) {
+ const preset = _cronSchedulePresetForId(presetId);
+ const defaults = (preset && preset.defaults) || {};
+ if (presetId === 'hourly') {
+ return { minute: _cronSchedulePresetValueForField('minute', defaults.minute) };
+ }
+ if (presetId === 'daily' || presetId === 'weekdays') {
+ return {
+ hour: _cronSchedulePresetValueForField('hour', defaults.hour),
+ minute: _cronSchedulePresetValueForField('minute', defaults.minute),
+ };
+ }
+ if (presetId === 'weekly') {
+ return {
+ hour: _cronSchedulePresetValueForField('hour', defaults.hour),
+ minute: _cronSchedulePresetValueForField('minute', defaults.minute),
+ weekday: _cronSchedulePresetValueForField('weekday', defaults.weekday),
+ };
+ }
+ if (presetId === 'monthly') {
+ return {
+ hour: _cronSchedulePresetValueForField('hour', defaults.hour),
+ minute: _cronSchedulePresetValueForField('minute', defaults.minute),
+ monthDay: _cronSchedulePresetValueForField('monthDay', defaults.monthDay),
+ };
+ }
+ return {};
+}
+
+function _cronSchedulePresetValueForSelection(presetId) {
+ const values = _cronSchedulePresetValuesForSelection(presetId);
+ if (presetId === 'hourly') return `${values.minute} * * * *`;
+ if (presetId === 'daily') return `${values.minute} ${values.hour} * * *`;
+ if (presetId === 'weekdays') return `${values.minute} ${values.hour} * * 1-5`;
+ if (presetId === 'weekly') return `${values.minute} ${values.hour} * * ${values.weekday}`;
+ if (presetId === 'monthly') return `${values.minute} ${values.hour} ${values.monthDay} * *`;
+ return '';
+}
+
+function _cronSchedulePresetStateForInput(value) {
const schedule = String(value || '').trim();
- const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.value && entry.value === schedule);
- return preset ? preset.id : 'custom';
+ if (!schedule) return { presetId: 'custom' };
+ if (/^every\s+1h$/i.test(schedule)) {
+ return { presetId: 'hourly', minute: '0' };
+ }
+ if (schedule.startsWith('@')) return { presetId: 'custom' };
+ const parts = schedule.split(/\s+/);
+ if (parts.length !== 5) return { presetId: 'custom' };
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
+ if (!_cronSchedulePresetRawFieldInBounds('minute', minute)) return { presetId: 'custom' };
+ if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
+ return { presetId: 'daily', minute, hour };
+ }
+ if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') {
+ return { presetId: 'weekdays', minute, hour };
+ }
+ if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
+ return { presetId: 'hourly', minute };
+ }
+ if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && _cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek)) {
+ return { presetId: 'weekly', minute, hour, weekday: dayOfWeek };
+ }
+ if (_cronSchedulePresetRawFieldInBounds('hour', hour) && _cronSchedulePresetRawFieldInBounds('monthDay', dayOfMonth) && month === '*' && dayOfWeek === '*') {
+ return { presetId: 'monthly', minute, hour, monthDay: dayOfMonth };
+ }
+ return { presetId: 'custom' };
+}
+
+function _cronSchedulePresetIdForValue(value) {
+ return _cronSchedulePresetStateForInput(value).presetId;
}
function _syncCronSchedulePresetFromInput() {
const presetEl = $('cronFormSchedulePreset');
const scheduleEl = $('cronFormSchedule');
if (!presetEl || !scheduleEl) return;
- const presetId = _cronSchedulePresetIdForValue(scheduleEl.value);
- presetEl.value = presetId;
+ const state = _cronSchedulePresetStateForInput(scheduleEl.value);
+ presetEl.value = state.presetId;
+ _cronSchedulePresetSyncVisibility(state.presetId);
+ if (state.presetId !== 'custom') _cronSchedulePresetApplyValues(state);
}
function _syncCronSchedulePresetAndWarning() {
@@ -505,20 +646,28 @@ function _applyCronSchedulePresetSelection() {
const scheduleEl = $('cronFormSchedule');
if (!presetEl || !scheduleEl) return;
const presetId = presetEl.value;
- const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId);
- if (preset && preset.value) {
- scheduleEl.value = preset.value;
+ if (presetId !== 'custom') {
+ scheduleEl.value = _cronSchedulePresetValueForSelection(presetId);
+ _cronSchedulePresetApplyValues(_cronSchedulePresetValuesForSelection(presetId));
_syncCronSchedulePresetAndWarning();
return;
}
+ _cronSchedulePresetSyncVisibility(presetId);
_syncCronScheduleWarning();
}
function _initCronSchedulePresetControls() {
const presetEl = $('cronFormSchedulePreset');
const scheduleEl = $('cronFormSchedule');
- if (!presetEl || !scheduleEl) return;
+ const presetParams = $('cronFormSchedulePresetParams');
+ if (!presetEl || !scheduleEl || !presetParams) return;
presetEl.addEventListener('change', _applyCronSchedulePresetSelection);
+ ['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => {
+ const el = $(id);
+ if (!el) return;
+ el.addEventListener('change', _applyCronSchedulePresetSelection);
+ el.addEventListener('input', _applyCronSchedulePresetSelection);
+ });
scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning);
scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning);
_syncCronSchedulePresetAndWarning();
@@ -1371,9 +1520,30 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
diff --git a/static/style.css b/static/style.css
index 70509695..2006cd07 100644
--- a/static/style.css
+++ b/static/style.css
@@ -6197,6 +6197,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-form-row > label{font-size:12px;font-weight:500;color:var(--muted);letter-spacing:.01em;}
.detail-form-row input[type="text"],
.detail-form-row input[type="password"],
+.detail-form-row input[type="number"],
.detail-form-row input:not([type]),
.detail-form-row select,
.detail-form-row textarea{
@@ -6208,10 +6209,18 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
}
.detail-form-row input[type="text"]:focus,
.detail-form-row input[type="password"]:focus,
+.detail-form-row input[type="number"]:focus,
.detail-form-row input:not([type]):focus,
.detail-form-row select:focus,
.detail-form-row textarea:focus{border-color:var(--accent,var(--accent-bg-strong,var(--border)));}
.detail-form-row input:disabled{opacity:.6;cursor:not-allowed;}
+.cron-schedule-preset-shell{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start;}
+.cron-schedule-preset-shell > select{width:auto;min-width:160px;flex:0 0 160px;}
+.cron-schedule-preset-params{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;min-width:0;}
+.cron-schedule-preset-field{display:flex;flex-direction:column;gap:4px;flex:0 0 110px;min-width:110px;}
+.cron-schedule-preset-field label{font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.01em;}
+.cron-schedule-preset-field input[type="number"]{width:100%;}
+.cron-schedule-preset-time-hint{flex-basis:100%;}
.detail-form-row textarea{resize:vertical;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;}
.detail-form-row .detail-form-hint{font-size:11px;color:var(--muted);line-height:1.5;}
.detail-form-warning{font-size:11px;line-height:1.5;border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:rgba(245,158,11,.98);border-radius:8px;padding:8px 10px;}
diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py
index 3ea70781..f2ccfbe9 100644
--- a/tests/test_issue2031_cron_once_visibility.py
+++ b/tests/test_issue2031_cron_once_visibility.py
@@ -81,19 +81,27 @@ def test_cron_schedule_preset_matching():
script = _cron_schedule_source() + r"""
const cases = {
hourly: _cronSchedulePresetIdForValue("every 1h"),
- daily: _cronSchedulePresetIdForValue("0 9 * * *"),
- weekdays: _cronSchedulePresetIdForValue("0 9 * * 1-5"),
- weekly: _cronSchedulePresetIdForValue("0 9 * * 1"),
- monthly: _cronSchedulePresetIdForValue("0 9 1 * *"),
+ hourlyCron: _cronSchedulePresetIdForValue("15 * * * *"),
+ daily: _cronSchedulePresetIdForValue("30 7 * * *"),
+ weekdays: _cronSchedulePresetIdForValue("5 22 * * 1-5"),
+ weekly: _cronSchedulePresetIdForValue("45 8 * * 3"),
+ monthly: _cronSchedulePresetIdForValue("10 6 15 * *"),
empty: _cronSchedulePresetIdForValue(""),
- trimMatch: _cronSchedulePresetIdForValue(" 0 9 * * * "),
+ trimMatch: _cronSchedulePresetIdForValue(" 30 7 * * * "),
custom: _cronSchedulePresetIdForValue("0 9 * * * 0"),
+ shorthand: _cronSchedulePresetIdForValue("@daily"),
+ invalidDaily: _cronSchedulePresetIdForValue("99 99 * * *"),
+ invalidHourly: _cronSchedulePresetIdForValue("60 * * * *"),
+ invalidWeekly: _cronSchedulePresetIdForValue("0 9 * * 8"),
+ invalidMonthDayZero: _cronSchedulePresetIdForValue("0 9 0 * *"),
+ invalidMonthly: _cronSchedulePresetIdForValue("0 9 32 * *"),
};
console.log(JSON.stringify(cases));
"""
cases = json.loads(_run_node(script))
assert cases["hourly"] == "hourly"
+ assert cases["hourlyCron"] == "hourly"
assert cases["daily"] == "daily"
assert cases["weekdays"] == "weekdays"
assert cases["weekly"] == "weekly"
@@ -101,16 +109,22 @@ console.log(JSON.stringify(cases));
assert cases["empty"] == "custom"
assert cases["trimMatch"] == "daily"
assert cases["custom"] == "custom"
+ assert cases["shorthand"] == "custom"
+ assert cases["invalidDaily"] == "custom"
+ assert cases["invalidHourly"] == "custom"
+ assert cases["invalidWeekly"] == "custom"
+ assert cases["invalidMonthDayZero"] == "custom"
+ assert cases["invalidMonthly"] == "custom"
def test_cron_schedule_preset_controls_sync_raw_and_preset_paths():
script = _cron_schedule_source() + r"""
const elements = {};
function $(id) { return elements[id]; }
-function makeElement() {
+function makeElement(initialValue = '') {
return {
- value: '',
- style: {},
+ value: initialValue,
+ style: { display: '' },
listeners: {},
addEventListener(type, handler) {
(this.listeners[type] || (this.listeners[type] = [])).push(handler);
@@ -135,64 +149,264 @@ function t(key) {
}
function esc(value) { return value == null ? '' : String(value); }
-elements.cronFormSchedule = makeElement();
-elements.cronFormSchedulePreset = makeElement();
-elements.cronFormScheduleOnceWarning = { style: {} };
+[
+ 'cronFormSchedule',
+ 'cronFormSchedulePreset',
+ 'cronFormSchedulePresetParams',
+ 'cronFormScheduleHourField',
+ 'cronFormScheduleMinuteField',
+ 'cronFormScheduleWeekdayField',
+ 'cronFormScheduleMonthDayField',
+ 'cronFormScheduleHour',
+ 'cronFormScheduleMinute',
+ 'cronFormScheduleWeekday',
+ 'cronFormScheduleMonthDay',
+].forEach((id) => {
+ elements[id] = makeElement();
+});
+elements.cronFormScheduleOnceWarning = { style: { display: 'none' } };
+elements.cronFormSchedulePresetParams.style.display = 'none';
_initCronSchedulePresetControls();
-elements.cronFormSchedule.value = "0 9 * * *";
-elements.cronFormSchedule.dispatchEvent('input');
-const selectAfterDaily = elements.cronFormSchedulePreset.value;
-const warningAfterDaily = elements.cronFormScheduleOnceWarning.style.display;
+function snapshot() {
+ return {
+ preset: elements.cronFormSchedulePreset.value,
+ schedule: elements.cronFormSchedule.value,
+ paramsDisplay: elements.cronFormSchedulePresetParams.style.display,
+ hourFieldDisplay: elements.cronFormScheduleHourField.style.display,
+ minuteFieldDisplay: elements.cronFormScheduleMinuteField.style.display,
+ weekdayFieldDisplay: elements.cronFormScheduleWeekdayField.style.display,
+ monthDayFieldDisplay: elements.cronFormScheduleMonthDayField.style.display,
+ hour: elements.cronFormScheduleHour.value,
+ minute: elements.cronFormScheduleMinute.value,
+ weekday: elements.cronFormScheduleWeekday.value,
+ monthDay: elements.cronFormScheduleMonthDay.value,
+ warning: elements.cronFormScheduleOnceWarning.style.display,
+ kind: _cronScheduleKindForInput(elements.cronFormSchedule.value),
+ };
+}
-elements.cronFormSchedule.value = "30m";
+elements.cronFormSchedule.value = '15 * * * *';
elements.cronFormSchedule.dispatchEvent('input');
-const selectAfterOnce = elements.cronFormSchedulePreset.value;
-const warningAfterOnce = elements.cronFormScheduleOnceWarning.style.display;
+const hourlySync = snapshot();
elements.cronFormSchedulePreset.value = 'hourly';
elements.cronFormSchedulePreset.dispatchEvent('change');
-const presetWriteValue = elements.cronFormSchedule.value;
-const selectAfterPresetWrite = elements.cronFormSchedulePreset.value;
+elements.cronFormScheduleMinute.value = '15';
+elements.cronFormScheduleMinute.dispatchEvent('change');
+const hourlyWrite = snapshot();
-elements.cronFormSchedulePreset.value = "custom";
-elements.cronFormSchedulePreset.dispatchEvent('change');
-const preservedExactPresetValue = elements.cronFormSchedule.value;
-const selectAfterExactPresetCustom = elements.cronFormSchedulePreset.value;
-
-elements.cronFormSchedule.value = "advanced: cron expression";
+elements.cronFormSchedule.value = '30 7 * * *';
elements.cronFormSchedule.dispatchEvent('input');
-elements.cronFormSchedulePreset.value = "custom";
+const dailySync = snapshot();
+elements.cronFormScheduleHour.value = '7';
+elements.cronFormScheduleHour.dispatchEvent('change');
+elements.cronFormScheduleMinute.value = '30';
+elements.cronFormScheduleMinute.dispatchEvent('change');
+const dailyWrite = snapshot();
+
+elements.cronFormSchedule.value = '5 22 * * 1-5';
+elements.cronFormSchedule.dispatchEvent('input');
+const weekdaysSync = snapshot();
+
+elements.cronFormSchedule.value = '45 8 * * 3';
+elements.cronFormSchedule.dispatchEvent('input');
+const weeklySync = snapshot();
+elements.cronFormScheduleWeekday.value = '3';
+elements.cronFormScheduleWeekday.dispatchEvent('change');
+elements.cronFormScheduleHour.value = '8';
+elements.cronFormScheduleHour.dispatchEvent('change');
+elements.cronFormScheduleMinute.value = '45';
+elements.cronFormScheduleMinute.dispatchEvent('change');
+const weeklyWrite = snapshot();
+
+elements.cronFormSchedule.value = '10 6 15 * *';
+elements.cronFormSchedule.dispatchEvent('input');
+const monthlySync = snapshot();
+elements.cronFormScheduleMonthDay.value = '15';
+elements.cronFormScheduleMonthDay.dispatchEvent('change');
+elements.cronFormScheduleHour.value = '6';
+elements.cronFormScheduleHour.dispatchEvent('change');
+elements.cronFormScheduleMinute.value = '10';
+elements.cronFormScheduleMinute.dispatchEvent('change');
+const monthlyWrite = snapshot();
+
+elements.cronFormSchedule.value = '30 7 * * *';
+elements.cronFormSchedule.dispatchEvent('input');
+const dailyAfterMonthlySync = snapshot();
+
+elements.cronFormSchedulePreset.value = 'monthly';
elements.cronFormSchedulePreset.dispatchEvent('change');
-const preservedCustomValue = elements.cronFormSchedule.value;
-const selectAfterCustom = elements.cronFormSchedulePreset.value;
+elements.cronFormScheduleHour.value = '99';
+elements.cronFormScheduleMinute.value = '-8';
+elements.cronFormScheduleMonthDay.value = '99';
+elements.cronFormScheduleMonthDay.dispatchEvent('change');
+const normalizedMonthlyWrite = snapshot();
+
+elements.cronFormSchedule.value = '@daily';
+elements.cronFormSchedule.dispatchEvent('input');
+const shorthandCustom = snapshot();
+
+elements.cronFormSchedule.value = 'advanced: cron expression';
+elements.cronFormSchedule.dispatchEvent('input');
+const unsupportedCustom = snapshot();
+
+elements.cronFormSchedulePreset.value = 'custom';
+elements.cronFormSchedulePreset.dispatchEvent('change');
+const customSelectionPreserved = snapshot();
console.log(JSON.stringify({
- selectAfterDaily,
- warningAfterDaily,
- selectAfterOnce,
- warningAfterOnce,
- presetWriteValue,
- selectAfterPresetWrite,
- preservedExactPresetValue,
- selectAfterExactPresetCustom,
- preservedCustomValue,
- selectAfterCustom,
+ hourlySync,
+ hourlyWrite,
+ dailySync,
+ dailyWrite,
+ weekdaysSync,
+ weeklySync,
+ weeklyWrite,
+ monthlySync,
+ monthlyWrite,
+ dailyAfterMonthlySync,
+ normalizedMonthlyWrite,
+ shorthandCustom,
+ unsupportedCustom,
+ customSelectionPreserved,
}));
"""
result = json.loads(_run_node(script))
- assert result["selectAfterDaily"] == "daily"
- assert result["warningAfterDaily"] == "none"
- assert result["selectAfterOnce"] == "custom"
- assert result["warningAfterOnce"] == ""
- assert result["presetWriteValue"] == "every 1h"
- assert result["selectAfterPresetWrite"] == "hourly"
- assert result["preservedExactPresetValue"] == "every 1h"
- assert result["selectAfterExactPresetCustom"] == "custom"
- assert result["preservedCustomValue"] == "advanced: cron expression"
- assert result["selectAfterCustom"] == "custom"
+ assert result["hourlySync"]["preset"] == "hourly"
+ assert result["hourlySync"]["schedule"] == "15 * * * *"
+ assert result["hourlySync"]["paramsDisplay"] == ""
+ assert result["hourlySync"]["hourFieldDisplay"] == "none"
+ assert result["hourlySync"]["minuteFieldDisplay"] == ""
+ assert result["hourlySync"]["kind"] == "cron"
+ assert result["hourlyWrite"]["schedule"] == "15 * * * *"
+ assert result["hourlyWrite"]["kind"] == "cron"
+
+ assert result["dailySync"]["preset"] == "daily"
+ assert result["dailySync"]["schedule"] == "30 7 * * *"
+ assert result["dailySync"]["hourFieldDisplay"] == ""
+ assert result["dailySync"]["minuteFieldDisplay"] == ""
+ assert result["dailySync"]["weekdayFieldDisplay"] == "none"
+ assert result["dailySync"]["monthDayFieldDisplay"] == "none"
+ assert result["dailyWrite"]["schedule"] == "30 7 * * *"
+ assert result["dailyWrite"]["kind"] == "cron"
+
+ assert result["weekdaysSync"]["preset"] == "weekdays"
+ assert result["weekdaysSync"]["schedule"] == "5 22 * * 1-5"
+ assert result["weekdaysSync"]["hourFieldDisplay"] == ""
+ assert result["weekdaysSync"]["minuteFieldDisplay"] == ""
+ assert result["weekdaysSync"]["weekdayFieldDisplay"] == "none"
+ assert result["weekdaysSync"]["monthDayFieldDisplay"] == "none"
+
+ assert result["weeklySync"]["preset"] == "weekly"
+ assert result["weeklySync"]["schedule"] == "45 8 * * 3"
+ assert result["weeklySync"]["weekdayFieldDisplay"] == ""
+ assert result["weeklySync"]["monthDayFieldDisplay"] == "none"
+ assert result["weeklyWrite"]["schedule"] == "45 8 * * 3"
+ assert result["weeklyWrite"]["kind"] == "cron"
+
+ assert result["monthlySync"]["preset"] == "monthly"
+ assert result["monthlySync"]["schedule"] == "10 6 15 * *"
+ assert result["monthlySync"]["monthDayFieldDisplay"] == ""
+ assert result["monthlySync"]["weekdayFieldDisplay"] == "none"
+ assert result["monthlyWrite"]["schedule"] == "10 6 15 * *"
+ assert result["monthlyWrite"]["kind"] == "cron"
+
+ assert result["dailyAfterMonthlySync"]["preset"] == "daily"
+ assert result["dailyAfterMonthlySync"]["schedule"] == "30 7 * * *"
+ assert result["dailyAfterMonthlySync"]["hour"] == "7"
+ assert result["dailyAfterMonthlySync"]["minute"] == "30"
+ assert result["dailyAfterMonthlySync"]["monthDayFieldDisplay"] == "none"
+
+ assert result["normalizedMonthlyWrite"]["schedule"] == "0 23 31 * *"
+ assert result["normalizedMonthlyWrite"]["hour"] == "23"
+ assert result["normalizedMonthlyWrite"]["minute"] == "0"
+ assert result["normalizedMonthlyWrite"]["monthDay"] == "31"
+ assert result["normalizedMonthlyWrite"]["kind"] == "cron"
+
+ assert result["shorthandCustom"]["preset"] == "custom"
+ assert result["shorthandCustom"]["schedule"] == "@daily"
+ assert result["shorthandCustom"]["paramsDisplay"] == "none"
+
+ assert result["unsupportedCustom"]["preset"] == "custom"
+ assert result["unsupportedCustom"]["schedule"] == "advanced: cron expression"
+ assert result["unsupportedCustom"]["paramsDisplay"] == "none"
+
+ assert result["customSelectionPreserved"]["preset"] == "custom"
+ assert result["customSelectionPreserved"]["schedule"] == "advanced: cron expression"
+ assert result["customSelectionPreserved"]["paramsDisplay"] == "none"
+
+
+def test_cron_schedule_custom_selection_preserves_raw_schedule_exactly():
+ script = _cron_schedule_source() + r"""
+const elements = {};
+function $(id) { return elements[id]; }
+function makeElement(initialValue = '') {
+ return {
+ value: initialValue,
+ style: { display: '' },
+ listeners: {},
+ addEventListener(type, handler) {
+ (this.listeners[type] || (this.listeners[type] = [])).push(handler);
+ },
+ dispatchEvent(eventType) {
+ const handlers = this.listeners[eventType] || [];
+ for (const handler of handlers) handler({ type: eventType, target: this });
+ },
+ };
+}
+function t(key) {
+ const dict = {
+ cron_schedule_preset_label: 'Preset',
+ cron_schedule_preset_hourly: 'Hourly',
+ cron_schedule_preset_daily: 'Daily',
+ cron_schedule_preset_weekdays: 'Weekdays',
+ cron_schedule_preset_weekly: 'Weekly',
+ cron_schedule_preset_monthly: 'Monthly',
+ cron_schedule_preset_custom: 'Custom',
+ };
+ return dict[key];
+}
+function esc(value) { return value == null ? '' : String(value); }
+
+[
+ 'cronFormSchedule',
+ 'cronFormSchedulePreset',
+ 'cronFormSchedulePresetParams',
+ 'cronFormScheduleHourField',
+ 'cronFormScheduleMinuteField',
+ 'cronFormScheduleWeekdayField',
+ 'cronFormScheduleMonthDayField',
+ 'cronFormScheduleHour',
+ 'cronFormScheduleMinute',
+ 'cronFormScheduleWeekday',
+ 'cronFormScheduleMonthDay',
+].forEach((id) => {
+ elements[id] = makeElement();
+});
+elements.cronFormScheduleOnceWarning = { style: { display: 'none' } };
+elements.cronFormSchedulePresetParams.style.display = 'none';
+
+_initCronSchedulePresetControls();
+
+elements.cronFormSchedule.value = 'advanced: cron expression';
+elements.cronFormSchedule.dispatchEvent('input');
+elements.cronFormSchedulePreset.value = 'custom';
+elements.cronFormSchedulePreset.dispatchEvent('change');
+
+console.log(JSON.stringify({
+ preset: elements.cronFormSchedulePreset.value,
+ schedule: elements.cronFormSchedule.value,
+ paramsDisplay: elements.cronFormSchedulePresetParams.style.display,
+}));
+"""
+ result = json.loads(_run_node(script))
+
+ assert result["preset"] == "custom"
+ assert result["schedule"] == "advanced: cron expression"
+ assert result["paramsDisplay"] == "none"
def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup():
@@ -202,6 +416,11 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup():
assert "id=\"cronFormScheduleOnceWarning\"" in panels
assert "id=\"cronFormSchedulePreset\"" in panels
+ assert "id=\"cronFormSchedulePresetParams\"" in panels
+ assert "id=\"cronFormScheduleHour\"" in panels
+ assert "id=\"cronFormScheduleMinute\"" in panels
+ assert "id=\"cronFormScheduleWeekday\"" in panels
+ assert "id=\"cronFormScheduleMonthDay\"" in panels
assert "cron_schedule_once_warning" in panels
assert "_cronSchedulePresetIdForValue" in panels
assert "_cronSchedulePresetOptionHtml" in panels
@@ -210,12 +429,15 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup():
assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels
assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels
assert ".cron-once-warning" in style
- assert "id: 'hourly'" in panels
- assert "id: 'daily'" in panels
- assert "id: 'weekdays'" in panels
- assert "id: 'weekly'" in panels
- assert "id: 'monthly'" in panels
- assert "id: 'custom'" in panels
+ assert ".cron-schedule-preset-shell" in style
+ assert ".cron-schedule-preset-params" in style
+ assert ".cron-schedule-preset-field" in style
+ assert ".cron-schedule-preset-time-hint" in style
+ assert "Time is server time; cron runs server-side." in panels
+ assert "fields: ['minute']" in panels
+ assert "fields: ['hour', 'minute']" in panels
+ assert "fields: ['hour', 'minute', 'weekday']" in panels
+ assert "fields: ['hour', 'minute', 'monthDay']" in panels
assert "Duration forms like '30m' run once" in i18n