339 lines
14 KiB
Python
339 lines
14 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
|
|
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
|
|
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
|
|
|
|
|
def run_node(script: str) -> dict:
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_plan_today_drafts_capacity_order_and_cancel_without_writing():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const saved = [];
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
limit: 3,
|
|
save: ids => saved.push(ids),
|
|
}});
|
|
planner.open([item(1), item(2)], [item(1), item(2), item(3), item(4)]);
|
|
const add = planner.toggle(item(3));
|
|
const full = planner.toggle(item(4));
|
|
const moved = planner.move('issue:stackchain/dashboard:3:', 'up');
|
|
const snapshot = planner.snapshot();
|
|
planner.cancel();
|
|
process.stdout.write(JSON.stringify({{add, full, moved, snapshot, afterCancel:planner.snapshot(), saved}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"add": "added",
|
|
"full": "full",
|
|
"moved": True,
|
|
"snapshot": {
|
|
"open": True,
|
|
"ids": [
|
|
"issue:stackchain/dashboard:1:",
|
|
"issue:stackchain/dashboard:3:",
|
|
"issue:stackchain/dashboard:2:",
|
|
],
|
|
"count": 3,
|
|
"limit": 3,
|
|
},
|
|
"afterCancel": {"open": False, "ids": [], "count": 0, "limit": 3},
|
|
"saved": [],
|
|
}
|
|
|
|
|
|
def test_plan_today_saves_exact_draft_and_starts_first_item_only_after_success():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const calls = [];
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
save: ids => {{ calls.push(['save', ...ids]); return true; }},
|
|
start: first => calls.push(['start', first.number]),
|
|
}});
|
|
planner.open([item(1)], [item(1), item(2)]);
|
|
planner.toggle(item(2));
|
|
const result = planner.commit({{start:true}});
|
|
process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"result": "saved",
|
|
"calls": [
|
|
["save", "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
|
["start", 1],
|
|
],
|
|
"snapshot": {"open": False, "ids": [], "count": 0, "limit": 5},
|
|
}
|
|
|
|
|
|
def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const saved = [];
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
save: plan => saved.push(plan),
|
|
}});
|
|
planner.open([item(1), item(2)], [item(1), item(2)]);
|
|
planner.setCapacity(90);
|
|
planner.setEstimate('issue:stackchain/dashboard:1:', 60);
|
|
planner.setEstimate('issue:stackchain/dashboard:2:', 45);
|
|
const over = planner.snapshot();
|
|
const confirmation = planner.commit();
|
|
const stillOpen = planner.snapshot().open;
|
|
const result = planner.commit({{confirmOverCapacity:true}});
|
|
process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
|
|
"""
|
|
result = run_node(script)
|
|
assert result["over"] == {
|
|
"open": True,
|
|
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
|
"count": 2,
|
|
"limit": 5,
|
|
"capacity_minutes": 90,
|
|
"estimates": {
|
|
"issue:stackchain/dashboard:1:": 60,
|
|
"issue:stackchain/dashboard:2:": 45,
|
|
},
|
|
"planned_minutes": 105,
|
|
"remaining_minutes": -15,
|
|
"unestimated_count": 0,
|
|
"over_capacity": True,
|
|
}
|
|
assert result["confirmation"] == "confirm-over-capacity"
|
|
assert result["stillOpen"] is True
|
|
assert result["result"] == "saved"
|
|
assert result["saved"] == [{
|
|
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
|
"capacity_minutes": 90,
|
|
"estimates": {
|
|
"issue:stackchain/dashboard:1:": 60,
|
|
"issue:stackchain/dashboard:2:": 45,
|
|
},
|
|
}]
|
|
|
|
|
|
def test_plan_today_applies_recap_actual_only_to_the_draft_after_explicit_action():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const saved = [];
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
save: plan => {{ saved.push(plan); return true; }},
|
|
}});
|
|
const first='issue:stackchain/dashboard:1:';
|
|
const second='issue:stackchain/dashboard:2:';
|
|
planner.open([item(1), item(2)], [item(1), item(2)], {{
|
|
capacity_minutes:120, estimates:{{[first]:30,[second]:45}}
|
|
}}, {{[first]:52,[second]:45}});
|
|
const before=planner.snapshot();
|
|
const ignored=planner.applyRecommendation(second);
|
|
const applied=planner.applyRecommendation(first);
|
|
const after=planner.snapshot();
|
|
const result=planner.commit();
|
|
process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}}));
|
|
"""
|
|
result = run_node(script)
|
|
assert result["before"]["estimates"] == {
|
|
"issue:stackchain/dashboard:1:": 30,
|
|
"issue:stackchain/dashboard:2:": 45,
|
|
}
|
|
assert result["before"]["recommendations"] == {"issue:stackchain/dashboard:1:": 52}
|
|
assert result["ignored"] is False
|
|
assert result["applied"] is True
|
|
assert result["after"]["estimates"]["issue:stackchain/dashboard:1:"] == 52
|
|
assert result["after"]["recommendations"] == {}
|
|
assert result["saved"][0]["estimates"] == {
|
|
"issue:stackchain/dashboard:1:": 52,
|
|
"issue:stackchain/dashboard:2:": 45,
|
|
}
|
|
|
|
|
|
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const events = [];
|
|
let scroll = 318;
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
save: () => true,
|
|
}});
|
|
planner.open([item(1)], [item(1), item(2)]);
|
|
const preview = createPlanTodayPreview({{
|
|
planner,
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
getScroll: () => scroll,
|
|
setScroll: value => {{ scroll = value; }},
|
|
onOpen: value => events.push(['open', value.number]),
|
|
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
|
|
}});
|
|
preview.open(item(2), 'preview-2');
|
|
scroll = 0;
|
|
const first = preview.close({{add:true}});
|
|
preview.open(item(2), 'preview-2');
|
|
scroll = 0;
|
|
const second = preview.close({{add:true}});
|
|
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"first": "added",
|
|
"second": "already-added",
|
|
"scroll": 318,
|
|
"events": [
|
|
["open", 2],
|
|
["close", 2, "preview-2"],
|
|
["open", 2],
|
|
["close", 2, "preview-2"],
|
|
],
|
|
"planner": {
|
|
"open": True,
|
|
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
|
"count": 2,
|
|
"limit": 5,
|
|
},
|
|
}
|
|
|
|
|
|
def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue():
|
|
script = f"""
|
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
|
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
|
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
|
const planner = createPlanToday({{
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
save: () => true,
|
|
}});
|
|
planner.open([], [item(2), item(3)]);
|
|
const preview = createPlanTodayPreview({{
|
|
planner,
|
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
|
}});
|
|
preview.open(item(2));
|
|
preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}});
|
|
const blockedState = preview.snapshot();
|
|
const blocked = preview.close({{add:true}});
|
|
const overridden = preview.close({{add:true, override:true}});
|
|
preview.open(item(3));
|
|
preview.setDependencies({{available:false, dependencies:[]}});
|
|
const unavailableState = preview.snapshot();
|
|
const unavailable = preview.close({{add:true}});
|
|
const unavailableOverride = preview.close({{add:true, override:true}});
|
|
process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}}));
|
|
"""
|
|
assert run_node(script) == {
|
|
"blockedState": {
|
|
"open": True,
|
|
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"},
|
|
"trigger": None,
|
|
"scroll": 0,
|
|
"dependencies_available": True,
|
|
"dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}],
|
|
"requires_override": True,
|
|
},
|
|
"blocked": "blocked",
|
|
"overridden": "added",
|
|
"unavailableState": {
|
|
"open": True,
|
|
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"},
|
|
"trigger": None,
|
|
"scroll": 0,
|
|
"dependencies_available": False,
|
|
"dependencies": [],
|
|
"requires_override": True,
|
|
},
|
|
"unavailable": "dependencies-unavailable",
|
|
"unavailableOverride": "added",
|
|
"planner": {
|
|
"open": True,
|
|
"ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"],
|
|
"count": 2,
|
|
"limit": 5,
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/plan-today.js"></script>' in html
|
|
assert 'id="plan-today"' in html
|
|
assert 'id="plan-today-sheet" role="dialog"' in html
|
|
assert 'id="plan-today-capacity"' in html
|
|
assert 'id="plan-today-available"' in html
|
|
assert 'inputmode="numeric" min="15" max="1440"' in html
|
|
assert 'data-plan-estimate="' in html
|
|
assert 'aria-label="Estimate for ' in html
|
|
assert "formatPlanMinutes(state.planned_minutes)" in html
|
|
assert "planToday.setCapacity" in html
|
|
assert "planToday.setEstimate" in html
|
|
assert 'data-plan-recommendation="' in html
|
|
assert "planToday.applyRecommendation" in html
|
|
assert "Use " in html and " as new estimate" in html
|
|
assert "todaySync.enqueueConfiguration" in html
|
|
assert "todayWork.runway(todayMyWork, state.index - 1)" in (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
|
assert "timer.totalElapsed()" in (Path(__file__).parents[1] / "frontend" / "today-timer.js").read_text()
|
|
assert "resetPlanTodayConfirmation()" in html
|
|
assert "result === 'confirm-over-capacity'" in html
|
|
assert 'id="save-and-start-today"' in html
|
|
assert "const planToday = createPlanToday({" in html
|
|
assert "todaySync.enqueue('remove'" in html
|
|
assert "todaySync.enqueue('add'" in html
|
|
assert "qs('#plan-today').addEventListener('click'" in html
|
|
assert ".plan-today-actions { position:sticky; bottom:0;" in html
|
|
assert ".plan-today-actions button { min-height:44px;" in html
|
|
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
|
|
assert ".plan-today-item { grid-template-columns:1fr; }" in html
|
|
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
|
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
|
|
assert '<script src="static/plan-today-preview.js"></script>' in html
|
|
assert 'data-plan-preview="' in html
|
|
assert 'id="add-plan-preview"' in html
|
|
assert "taskOverlayHistory.open('plan-today-preview')" in html
|
|
assert "planTodayPreview.close({ add:true })" in html
|
|
assert 'id="issue-blockers"' in html
|
|
assert "planTodayPreview.setDependencies({" in html
|
|
assert "Add blocked item anyway & back" in html
|
|
assert "Blocker status unavailable" in html
|
|
assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html
|
|
assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html
|
|
assert "Preview unavailable offline" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
|
|
html = await dashboard()
|
|
|
|
assert "taskOverlayHistory.open('plan-today')" in html
|
|
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
|
|
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
|
|
assert "openPlanToday(planTodayTrigger, false)" in html
|
|
assert "closePlanToday(false)" in html
|
|
assert "if (result === 'saved')" in html
|
|
assert "taskOverlayHistory.leave();" in html
|
|
|
|
|
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
|
source = SERVICE_WORKER.read_text()
|
|
|
|
assert "stackchain-dashboard-shell-v92" in source
|
|
assert "BASE + 'static/plan-today.js'" in source
|
|
assert "BASE + 'static/plan-today-preview.js'" in source
|