stackchain-dashboard/tests/test_week_calendar.py
timmy f0a966d3a3
All checks were successful
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m0s
CI / release-candidate (pull_request) Has been skipped
feat: export saved Week Ahead from overview (Closes #1260)
2026-08-22 11:15:10 +00:00

168 lines
8.0 KiB
Python

import json
import subprocess
from pathlib import Path
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
def run_node(source: str):
completed = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_week_calendar_builds_ordered_local_time_blocks_and_serializes_opaque_events():
plan = {
"timezone": "America/New_York",
"revision": 7,
"days": [{
"plan_date": "2026-08-21",
"ids": ["issue:stackchain/dashboard:12:", "pull:stackchain/api:7:"],
"capacity_minutes": 120,
"estimates": {
"issue:stackchain/dashboard:12:": 45,
"pull:stackchain/api:7:": 30,
},
}],
}
items = {
"issue:stackchain/dashboard:12:": {
"id": 401,
"title": "Plan, ship; verify",
"repository": "stackchain/dashboard",
"number": 12,
"url": "https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12",
},
"pull:stackchain/api:7:": {
"title": "API release",
"repository": "stackchain/api",
"number": 7,
"url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/7",
},
}
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const plan={json.dumps(plan)}, items={json.dumps(items)};
const blocks=calendar.buildWeekBlocks(plan, {{'2026-08-21':'09:00'}}, id=>items[id]);
const text=calendar.serializeWeekCalendar(blocks, {{timezone:plan.timezone,revision:plan.revision,generatedAt:'20260820T120000Z'}});
console.log(JSON.stringify({{blocks,text}}));
"""
result = run_node(source)
assert [(block["start_time"], block["end_time"]) for block in result["blocks"]] == [
("09:00", "09:45"), ("09:45", "10:15")
]
text = result["text"]
assert "TZID:America/New_York" in text
assert "DTSTART;TZID=America/New_York:20260821T090000" in text
assert "DTEND;TZID=America/New_York:20260821T094500" in text
assert "DTSTART;TZID=America/New_York:20260821T094500" in text
assert "DTEND;TZID=America/New_York:20260821T101500" in text
assert "TRANSP:OPAQUE" in text
assert "UID:week-issue-stackchain-dashboard-12@stackchain" in text
assert "SEQUENCE:7" in text
assert "SUMMARY:Plan\\, ship\\; verify" in text
unfolded = text.replace("\r\n ", "")
assert "URL:https://forge.alexanderwhitestone.com/git/stackchain/stackchain-dashboard/issues/12" in unfolded
def test_week_calendar_reexports_update_stable_distinct_work_events():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const issue={{id:'issue:stackchain/dashboard:12:',title:'Ship it',repository:'stackchain/dashboard',number:12,
plan_date:'2026-08-21',start_time:'09:00',end_time:'09:30'}};
const moved={{...issue,plan_date:'2026-08-22',start_time:'14:00',end_time:'14:30'}};
const pull={{...moved,id:'pull:stackchain/dashboard:12:'}};
const first=calendar.serializeWeekCalendar([issue],{{timezone:'UTC',revision:7,generatedAt:'20260820T120000Z'}});
const second=calendar.serializeWeekCalendar([moved,pull],{{timezone:'UTC',revision:8,generatedAt:'20260821T120000Z'}});
const events=text=>text.split('BEGIN:VEVENT').slice(1).map(value=>({{
uid:value.match(/UID:([^\\r]+)/)[1],sequence:Number(value.match(/SEQUENCE:(\\d+)/)[1])
}}));
console.log(JSON.stringify({{first:events(first),second:events(second)}}));
"""
result = run_node(source)
assert result["first"] == [{
"uid": "week-issue-stackchain-dashboard-12@stackchain",
"sequence": 7,
}]
assert result["second"] == [
{"uid": "week-issue-stackchain-dashboard-12@stackchain", "sequence": 8},
{"uid": "week-pull-stackchain-dashboard-12@stackchain", "sequence": 8},
]
def test_week_calendar_places_whole_tasks_in_imported_free_windows_and_reports_unfitted_work():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two','three'],estimates:{{one:45,two:90,three:400}}}}]}};
const items={{one:{{title:'First',repository:'r',number:1}},two:{{title:'Second',repository:'r',number:2}},
three:{{title:'Too large',repository:'r',number:3}}}};
const availability=[{{plan_date:'2026-08-21',free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'11:00',end_time:'17:00'}}]}}];
const result=calendar.buildWeekSchedule(plan,{{'2026-08-21':'09:00'}},id=>items[id],null,availability);
console.log(JSON.stringify(result));
"""
result = run_node(source)
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["blocks"]] == [
("one", "09:00", "09:45"),
("two", "11:00", "12:30"),
]
assert result["blockers"] == [{
"id": "three", "title": "Too large", "plan_date": "2026-08-21", "minutes": 400
}]
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
source = f"""
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
const plan={{timezone:'UTC',revision:2,days:[{{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:60,estimates:{{one:20,two:25}}}}]}};
const items={{one:{{title:'Private',repository:'r',number:1,url:'https://forge/r/1'}},two:{{title:'Public',repository:'r',number:2,url:'https://forge/r/2'}}}};
const blocks=calendar.buildWeekBlocks(plan,{{'2026-08-21':'13:00'}},id=>items[id],new Set(['two']));
class FakeFile {{constructor(parts,name,options){{this.parts=parts;this.name=name;this.type=options.type;}}}}
const clicks=[],revoked=[];
(async()=>{{
const result=await calendar.deliverWeekCalendar({{text:'calendar',filename:'week.ics',navigator:{{}},FileCtor:FakeFile,
document:{{createElement:()=>({{click(){{clicks.push([this.download,this.href]);}}}})}},
urlApi:{{createObjectURL:()=> 'blob:week',revokeObjectURL:value=>revoked.push(value)}}}});
console.log(JSON.stringify({{blocks,result,clicks,revoked}}));
}})();
"""
result = run_node(source)
assert [block["id"] for block in result["blocks"]] == ["two"]
assert result["blocks"][0]["start_time"] == "13:00"
assert result["result"] == "downloaded"
assert result["clicks"] == [["week.ics", "blob:week"]]
assert result["revoked"] == ["blob:week"]
def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_controls():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
bundle = BUNDLE.read_text()
assert '<script src="static/week-calendar.js"></script>' in index
assert 'id="week-calendar-handoff"' in index
assert 'id="week-calendar-days"' in index
assert 'id="share-week-calendar"' in index
assert 'id="back-to-week-review-from-calendar"' in index
assert 'id="export-saved-week-calendar"' in index
assert "Add saved week to calendar" in index
assert "Titles, references, dates, and times are included" in index
assert "Imported busy time is used only until this page reloads" in index
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
assert "wc.open(weekPlan.state())" in dashboard
assert "getItem:weekFlow.i" in dashboard
assert "i:id=>getItem(id)||controller.item?.(id)" in (WEEK_CALENDAR.parent / "week-plan.js").read_text()
assert "#export-saved-week-calendar" in WEEK_CALENDAR.read_text()
assert "focusReturn=event.currentTarget" in WEEK_CALENDAR.read_text()
assert "wc.close()" in dashboard
assert "StackchainWeekCalendar.setAvailabilityProvider(workflow.availability)" in (WEEK_CALENDAR.parent / "week-calendar-import.js").read_text()
assert "Planning around imported busy time" in WEEK_CALENDAR.read_text()
assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle