stage #4031 onto master (v0.51.558) — polish cron model picker copy + regression tests
TomBanksAU: cron model picker copy (esc()+i18n fallbacks, no_agent hint for script-only jobs) + 195-line provider-picker regression test. Core picker already shipped; this is polish + guardrails. Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
This commit is contained in:
parent
1b194a397c
commit
675bc7d9e7
2 changed files with 198 additions and 3 deletions
|
|
@ -711,7 +711,7 @@ function _renderCronDetail(job){
|
|||
job.provider && job.model ? `${esc(job.provider)}/${esc(job.model)}` :
|
||||
job.model ? esc(job.model) :
|
||||
job.provider ? esc(job.provider) :
|
||||
isNoAgent ? '' : 'default';
|
||||
isNoAgent ? '' : esc(t('cron_model_use_default') || 'Use profile default');
|
||||
const profileLabel = _cronProfileLabel(job.profile);
|
||||
const profileTitle = _cronProfileTitle(job.profile);
|
||||
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
|
||||
|
|
@ -1091,11 +1091,11 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
|
|||
<div class="detail-form-hint">${esc(t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time')}</div>
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormModel">${esc(t('cron_model_label') || 'Model Override')}</label>
|
||||
<label for="cronFormModel">${esc(t('cron_model_label') || 'Model')}</label>
|
||||
<select id="cronFormModel"${isNoAgent ? ' disabled' : ''}>
|
||||
<option value="">loading...</option>
|
||||
</select>
|
||||
<div class="detail-form-hint">${esc(t('cron_model_hint') || 'Override the default model for this job.')}</div>
|
||||
<div class="detail-form-hint">${esc(isNoAgent ? (t('cron_model_no_agent_hint') || 'No-agent jobs run the configured script directly; model is unused.') : (t('cron_model_hint') || 'Use the profile default model at run time, or pin this job to a specific provider/model.'))}</div>
|
||||
</div>
|
||||
<div class="detail-form-row">
|
||||
<label for="cronFormToastNotifications">${esc(t('cron_toast_notifications_label') || 'Completion toasts')}</label>
|
||||
|
|
|
|||
195
tests/test_cron_model_provider_picker.py
Normal file
195
tests/test_cron_model_provider_picker.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Coverage for model-aware cron job creation/editing in the WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class _JSONHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = {}
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _payload(handler):
|
||||
return json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def _function_body(name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = PANELS_JS.find(marker)
|
||||
assert start != -1, f"{name} not found"
|
||||
paren = PANELS_JS.find("(", start)
|
||||
assert paren != -1, f"{name} params not found"
|
||||
depth = 0
|
||||
for idx in range(paren, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
brace = PANELS_JS.find("{", idx)
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"{name} params did not terminate")
|
||||
assert brace != -1, f"{name} body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return PANELS_JS[brace + 1 : idx]
|
||||
raise AssertionError(f"{name} body did not terminate")
|
||||
|
||||
|
||||
def test_cron_create_persists_model_provider_pair(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
created = {"id": "job-model", "name": "Model aware", "prompt": "ping"}
|
||||
calls = []
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or {**created, **kwargs}
|
||||
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or {**created, **updates}
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_create(
|
||||
handler,
|
||||
{
|
||||
"prompt": "ping",
|
||||
"schedule": "every 1h",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 200
|
||||
assert calls[0] == (
|
||||
"create",
|
||||
{
|
||||
"prompt": "ping",
|
||||
"schedule": "every 1h",
|
||||
"name": None,
|
||||
"deliver": "local",
|
||||
"skills": [],
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
},
|
||||
)
|
||||
assert _payload(handler)["job"]["provider"] == "openai-codex"
|
||||
assert _payload(handler)["job"]["model"] == "gpt-5.4"
|
||||
|
||||
|
||||
def test_cron_create_omits_blank_model_provider_to_use_profile_default(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or {"id": "job-default", **kwargs}
|
||||
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or {"id": job_id, **updates}
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_create(
|
||||
handler,
|
||||
{
|
||||
"prompt": "ping",
|
||||
"schedule": "every 1h",
|
||||
"model": "",
|
||||
"provider": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 200
|
||||
assert calls[0][1]["model"] is None
|
||||
assert calls[0][1]["provider"] is None
|
||||
|
||||
|
||||
def test_cron_update_can_set_and_clear_model_provider_pair(monkeypatch):
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
|
||||
def update_job(job_id, updates):
|
||||
calls.append((job_id, updates))
|
||||
return {"id": job_id, "name": "Updated", **updates}
|
||||
|
||||
cron_jobs.update_job = update_job
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
set_handler = _JSONHandler()
|
||||
routes._handle_cron_update(
|
||||
set_handler,
|
||||
{"job_id": "job-model", "model": "gpt-5.4", "provider": "openai-codex"},
|
||||
)
|
||||
assert set_handler.status == 200
|
||||
|
||||
clear_handler = _JSONHandler()
|
||||
routes._handle_cron_update(clear_handler, {"job_id": "job-model", "model": "", "provider": ""})
|
||||
assert clear_handler.status == 200
|
||||
|
||||
assert calls == [
|
||||
("job-model", {"model": "gpt-5.4", "provider": "openai-codex"}),
|
||||
("job-model", {"model": None, "provider": None}),
|
||||
]
|
||||
|
||||
|
||||
def test_cron_form_has_model_picker_and_saves_model_provider_state():
|
||||
render_body = _function_body("_renderCronForm")
|
||||
save_body = _function_body("saveCronForm")
|
||||
edit_body = _function_body("openCronEdit")
|
||||
detail_body = _function_body("_renderCronDetail")
|
||||
|
||||
picker_body = _function_body("_populateCronFormModelSelect")
|
||||
|
||||
assert "cronFormModel" in render_body
|
||||
assert "cron_model_label" in render_body
|
||||
assert "cron_model_use_default" in picker_body
|
||||
assert "_populateCronFormModelSelect" in render_body
|
||||
assert "_modelStateForSelect" in save_body
|
||||
assert "updates.model" in save_body
|
||||
assert "updates.provider" in save_body
|
||||
assert "body.model" in save_body
|
||||
assert "body.provider" in save_body
|
||||
assert "model: job.model" in edit_body
|
||||
assert "provider: job.provider" in edit_body
|
||||
assert "cron_model_use_default" in detail_body
|
||||
|
||||
|
||||
def test_cron_model_picker_i18n_keys_exist():
|
||||
assert "cron_model_label" in I18N_JS
|
||||
assert "cron_model_use_default" in I18N_JS
|
||||
assert "cron_model_hint" in I18N_JS
|
||||
Loading…
Reference in a new issue