120 lines
4.6 KiB
Python
120 lines
4.6 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REPLAN = ROOT / "frontend" / "agenda-replan.js"
|
|
|
|
|
|
def run_node(script):
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_overdue_replan_sweep_orders_items_and_only_advances_after_confirmed_change():
|
|
script = f"""
|
|
const createSweep = require({json.dumps(str(REPLAN))});
|
|
const calls = [];
|
|
let fail = true;
|
|
const items = [
|
|
{{key:'o/r#3', repository:'o/r', number:3, due_date:'2026-08-11T18:00:00Z'}},
|
|
{{key:'o/r#1', repository:'o/r', number:1, due_date:'2026-08-09T18:00:00Z'}},
|
|
{{key:'o/r#2', repository:'o/r', number:2, due_date:'2026-08-10T18:00:00Z'}},
|
|
];
|
|
const sweep = createSweep({{
|
|
now: () => new Date('2026-08-12T12:00:00'),
|
|
update: async (item, due) => {{ calls.push([item.key, due]); if (fail) throw new Error('offline'); return {{due_date:due}}; }},
|
|
}});
|
|
async function run() {{
|
|
const started = sweep.start(items);
|
|
const failed = await sweep.tomorrow();
|
|
const afterFailure = sweep.snapshot();
|
|
fail = false;
|
|
const changed = await sweep.tomorrow();
|
|
const afterChange = sweep.snapshot();
|
|
const kept = await sweep.keep();
|
|
process.stdout.write(JSON.stringify({{started, failed, afterFailure, changed, afterChange, kept, calls}}));
|
|
}}
|
|
run();
|
|
"""
|
|
assert run_node(script) == {
|
|
"started": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
|
"failed": {"ok": False, "error": "offline"},
|
|
"afterFailure": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
|
|
"changed": {"ok": True, "done": False},
|
|
"afterChange": {"active": True, "index": 1, "total": 3, "current": "o/r#2", "pending": False},
|
|
"kept": {"ok": True, "done": False},
|
|
"calls": [["o/r#1", "2026-08-13T23:59:59Z"], ["o/r#1", "2026-08-13T23:59:59Z"]],
|
|
}
|
|
|
|
|
|
def test_overdue_replan_rejects_non_future_choice_and_prevents_parallel_updates():
|
|
script = f"""
|
|
const createSweep = require({json.dumps(str(REPLAN))});
|
|
let resolveUpdate;
|
|
let calls = 0;
|
|
const sweep = createSweep({{
|
|
now: () => new Date('2026-08-12T12:00:00'),
|
|
update: async () => {{ calls += 1; await new Promise(resolve => resolveUpdate = resolve); return {{}}; }},
|
|
}});
|
|
async function run() {{
|
|
sweep.start([{{key:'o/r#1', due_date:'2026-08-10T18:00:00Z'}}]);
|
|
const invalid = await sweep.choose('2026-08-12');
|
|
const first = sweep.choose('2026-08-14');
|
|
const duplicate = await sweep.choose('2026-08-15');
|
|
resolveUpdate();
|
|
const confirmed = await first;
|
|
process.stdout.write(JSON.stringify({{invalid, duplicate, confirmed, calls, final:sweep.snapshot()}}));
|
|
}}
|
|
run();
|
|
"""
|
|
assert run_node(script) == {
|
|
"invalid": {"ok": False, "error": "Choose a future date."},
|
|
"duplicate": {"ok": False, "error": "Deadline update already in progress."},
|
|
"confirmed": {"ok": True, "done": True},
|
|
"calls": 1,
|
|
"final": {"active": False, "index": 1, "total": 1, "current": None, "pending": False},
|
|
}
|
|
|
|
|
|
def test_overdue_replan_orders_by_calendar_day_without_timezone_drift():
|
|
script = f"""
|
|
const createSweep = require({json.dumps(str(REPLAN))});
|
|
const sweep = createSweep({{update:async()=>({{}})}});
|
|
const started = sweep.start([
|
|
{{key:'o/r#2',repository:'o/r',number:2,due_date:'2026-08-13T01:00:00Z'}},
|
|
{{key:'o/r#1',repository:'o/r',number:1,due_date:'2026-08-12T23:59:59Z'}},
|
|
]);
|
|
process.stdout.write(JSON.stringify(started));
|
|
"""
|
|
environment = {**__import__('os').environ, "TZ": "Asia/Tokyo"}
|
|
result = subprocess.run(
|
|
["node", "-e", script], capture_output=True, text=True, env=environment
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout)["current"] == "o/r#1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation():
|
|
html = await dashboard()
|
|
|
|
assert 'id="start-agenda-replan"' in html
|
|
assert 'id="agenda-replan-controls"' in html
|
|
assert 'id="agenda-replan-progress"' in html
|
|
assert 'id="agenda-replan-keep"' in html
|
|
assert 'id="agenda-replan-tomorrow"' in html
|
|
assert 'id="agenda-replan-date"' in html
|
|
assert 'id="agenda-replan-choose"' in html
|
|
assert 'id="agenda-replan-cancel"' in html
|
|
assert "agendaReplan.start(overdue)" in html
|
|
assert "issueController.updateDueDate(item, dueDate)" in html
|
|
assert ".agenda-replan-actions button { min-height:44px;" in html
|
|
assert "@media(max-width:360px)" in html
|