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