stackchain-dashboard/tests/test_mobile_agenda_replan.py
timmy dce9ae04c9
All checks were successful
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
fix: preserve deadline calendar days (Closes #719)
2026-08-13 07:56:26 +00:00

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