401 lines
23 KiB
Python
401 lines
23 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
MODULE = Path(__file__).parents[1] / "frontend" / "week-calendar-import.js"
|
|
FRONTEND = MODULE.parent
|
|
|
|
|
|
def run_import(scenario: str) -> dict:
|
|
harness = f"""
|
|
const calendarImport = require({json.dumps(str(MODULE))});
|
|
(async() => {{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}})
|
|
"""
|
|
completed = subprocess.run(
|
|
["node", "-e", harness], check=True, capture_output=True, text=True
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def test_calendar_import_unions_busy_time_within_working_hours_across_local_days():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:one\r\nSUMMARY:Private planning\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:two\r\nSUMMARY:Secret customer call\r\nATTENDEE:mailto:private@example.com\r\nDTSTART:20260821T103000\r\nDTEND:20260821T120000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:outside\r\nDTSTART:20260821T070000\r\nDTEND:20260821T080000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:overnight\r\nLOCATION:Private office\r\nDTSTART:20260821T163000\r\nDTEND:20260822T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({days,serialized:JSON.stringify(days)}));
|
|
""")
|
|
|
|
assert result["days"][0] == {
|
|
"plan_date": "2026-08-21", "busy_minutes": 150, "capacity_minutes": 330,
|
|
"free_windows": [
|
|
{"start_time": "09:00", "end_time": "10:00"},
|
|
{"start_time": "12:00", "end_time": "16:30"},
|
|
],
|
|
}
|
|
assert result["days"][1] == {
|
|
"plan_date": "2026-08-22", "busy_minutes": 60, "capacity_minutes": 420,
|
|
"free_windows": [{"start_time": "10:00", "end_time": "17:00"}],
|
|
}
|
|
assert [day["capacity_minutes"] for day in result["days"][2:]] == [480] * 5
|
|
for private_value in ("Private planning", "Secret customer call", "private@example.com", "Private office"):
|
|
assert private_value not in result["serialized"]
|
|
|
|
|
|
def test_calendar_import_converts_iana_tzid_into_the_device_local_workday():
|
|
result = run_import("""
|
|
process.env.TZ='America/Los_Angeles';
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({first:days[0]}));
|
|
""")
|
|
|
|
assert result["first"]["busy_minutes"] == 60
|
|
assert result["first"]["free_windows"] == [
|
|
{"start_time": "10:00", "end_time": "17:00"},
|
|
]
|
|
|
|
|
|
def test_calendar_import_fails_closed_for_unknown_tzid_without_exposing_event_details():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private acquisition\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART;TZID=Private/Boardroom:20260821T120000\r\nDTEND;TZID=Private/Boardroom:20260821T130000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({unsupported:days.unsupported_count,busy:days.map(day=>day.busy_minutes),serialized:JSON.stringify(days)}));
|
|
""")
|
|
|
|
assert result["unsupported"] == 1
|
|
assert result["busy"] == [0] * 7
|
|
assert "Private acquisition" not in result["serialized"]
|
|
assert "secret@example.com" not in result["serialized"]
|
|
|
|
|
|
def test_calendar_import_applies_tzid_to_rdate_and_exdate_values():
|
|
result = run_import("""
|
|
process.env.TZ='America/Los_Angeles';
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260821T120000\r\nDTEND;TZID=America/New_York:20260821T130000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEXDATE;TZID=America/New_York:20260822T120000\r\nRDATE;TZID=America/New_York:20260824T120000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes),fourth:days[3].free_windows}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 0, 60, 60, 0, 0, 0]
|
|
assert result["fourth"] == [{"start_time": "10:00", "end_time": "17:00"}]
|
|
|
|
|
|
def test_calendar_import_keeps_zoned_daily_recurrence_at_wall_time_across_dst():
|
|
result = run_import("""
|
|
process.env.TZ='UTC';
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({windows:days.slice(0,3).map(day=>day.free_windows)}));
|
|
""")
|
|
|
|
assert result["windows"] == [
|
|
[{"start_time": "09:00", "end_time": "14:00"}, {"start_time": "15:00", "end_time": "17:00"}],
|
|
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
|
|
[{"start_time": "09:00", "end_time": "13:00"}, {"start_time": "14:00", "end_time": "17:00"}],
|
|
]
|
|
|
|
|
|
def test_calendar_import_interprets_floating_until_in_the_event_timezone():
|
|
result = run_import("""
|
|
process.env.TZ='UTC';
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20260307T090000\r\nDTEND;TZID=America/New_York:20260307T100000\r\nRRULE:FREQ=DAILY;UNTIL=20260308T090000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-03-07','2026-03-08','2026-03-09','2026-03-10','2026-03-11','2026-03-12','2026-03-13'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 60, 0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_calendar_import_fast_forwards_historical_daily_series_to_seven_day_range():
|
|
result = run_import("""
|
|
process.env.TZ='UTC';
|
|
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n`;
|
|
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60] * 7
|
|
assert result["elapsed"] < 1000
|
|
|
|
|
|
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
|
|
result = run_import("""
|
|
process.env.TZ='UTC';
|
|
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
|
|
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
|
|
assert result["elapsed"] < 1000
|
|
|
|
|
|
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:standup\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;COUNT=5\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 60, 0, 60, 60, 0, 0]
|
|
|
|
|
|
def test_calendar_import_adds_rdates_and_excludes_matching_occurrences():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRDATE:20260823T100000,20260825T100000\r\nEXDATE:20260823T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 0, 0, 0, 60, 0, 0]
|
|
|
|
|
|
def test_calendar_import_stops_recurrence_at_until_boundary():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;UNTIL=20260823T100000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 60, 60, 0, 0, 0, 0]
|
|
|
|
|
|
def test_calendar_import_expands_weekly_recurrence_on_selected_weekdays():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [60, 0, 0, 60, 0, 60, 0]
|
|
|
|
|
|
def test_calendar_import_treats_all_day_events_as_busy_for_working_hours():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20260822\r\nDTEND;VALUE=DATE:20260823\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [0, 480, 0, 0, 0, 0, 0]
|
|
|
|
|
|
def test_calendar_import_ignores_cancelled_and_transparent_events():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSTATUS:CANCELLED\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nTRANSP:TRANSPARENT\r\nDTSTART:20260822T100000\r\nDTEND:20260822T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
|
""")
|
|
|
|
assert result["busy"] == [0] * 7
|
|
|
|
|
|
def test_calendar_import_rejects_invalid_and_oversized_files_without_returning_capacity():
|
|
result = run_import("""
|
|
const errors=[];
|
|
for(const source of ['not a calendar','BEGIN:VCALENDAR\\r\\n'+('X'.repeat(1024*1024))+'\\r\\nEND:VCALENDAR']) {
|
|
try { calendarImport.review(source,{dates:['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']}); }
|
|
catch(error) { errors.push(error.message); }
|
|
}
|
|
console.log(JSON.stringify({errors}));
|
|
""")
|
|
|
|
assert result["errors"] == [
|
|
"Choose a valid .ics calendar file.",
|
|
"Calendar files must be 1 MB or smaller.",
|
|
]
|
|
|
|
|
|
def test_calendar_import_blocks_apply_when_recurrence_cannot_be_counted_truthfully():
|
|
result = run_import("""
|
|
const elements=new Map();
|
|
const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
|
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
|
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
|
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#apply-week-capacities','#cancel-week-capacity-import'])
|
|
elements.set(selector,element());
|
|
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
let staged=0;
|
|
const controller={dates:()=>dates.map(date=>({date,label:date})),stageCapacities:()=>{staged+=1;return true;},flush:async()=>{}};
|
|
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nSUMMARY:Private board review\r\nATTENDEE:mailto:secret@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=MONTHLY;BYDAY=1FR\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
workflow.review(source);const applied=await workflow.apply();
|
|
console.log(JSON.stringify({disabled:elements.get('#apply-week-capacities').disabled,status:elements.get('#week-capacity-status').textContent,
|
|
markup:elements.get('#week-capacity-days').innerHTML,applied,staged}));
|
|
""")
|
|
|
|
assert result["disabled"] is True
|
|
assert result["applied"] is False
|
|
assert result["staged"] == 0
|
|
assert "1 recurring event could not be counted" in result["status"]
|
|
assert "Apply is unavailable" in result["status"]
|
|
assert "Private board review" not in json.dumps(result)
|
|
assert "secret@example.com" not in json.dumps(result)
|
|
|
|
|
|
def test_calendar_import_marks_unsupported_daily_rule_parts_instead_of_ignoring_them():
|
|
result = run_import("""
|
|
const source=`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nRRULE:FREQ=DAILY;BYHOUR=10\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const days=calendarImport.review(source,{dates});
|
|
console.log(JSON.stringify({unsupported:days.unsupported_count}));
|
|
""")
|
|
|
|
assert result["unsupported"] == 1
|
|
|
|
|
|
def test_calendar_import_workflow_reviews_then_applies_once_without_persisting_calendar_text():
|
|
result = run_import("""
|
|
const elements=new Map();
|
|
const element=(value='')=>({value,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
|
addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;}});
|
|
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
|
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#apply-week-capacities','#cancel-week-capacity-import'])
|
|
elements.set(selector,element());
|
|
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
let staged=[],flushes=0,appliedCalls=0;
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),
|
|
stageCapacities:value=>{staged.push(value);return {sync_pending:true};},flush:async()=>{flushes+=1;}};
|
|
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector),onApplied:()=>{appliedCalls+=1;}});
|
|
workflow.open();
|
|
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Do not retain me\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const reviewed=workflow.review(source);
|
|
await workflow.apply();
|
|
console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elements.get('#week-capacity-import').hidden,
|
|
status:elements.get('#week-capacity-status').textContent,markup:elements.get('#week-capacity-days').innerHTML,
|
|
fileValue:elements.get('#week-capacity-file').value,workflowState:workflow.state(),availability:workflow.availability()}));
|
|
""")
|
|
|
|
assert result["reviewed"][0]["capacity_minutes"] == 420
|
|
assert len(result["staged"]) == 1
|
|
assert len(result["staged"][0]) == 7
|
|
assert result["flushes"] == 1
|
|
assert result["appliedCalls"] == 1
|
|
assert result["rootHidden"] is True
|
|
assert result["fileValue"] == ""
|
|
assert result["workflowState"] is None
|
|
assert result["availability"][0] == {
|
|
"plan_date": "2026-08-21",
|
|
"free_windows": [
|
|
{"start_time": "09:00", "end_time": "10:00"},
|
|
{"start_time": "11:00", "end_time": "17:00"},
|
|
],
|
|
}
|
|
assert "free_windows" not in json.dumps(result["staged"])
|
|
assert "Do not retain me" not in json.dumps(result)
|
|
|
|
|
|
def test_calendar_import_persists_only_free_windows_with_explicit_consent_and_restores_them():
|
|
result = run_import("""
|
|
const elements=new Map();
|
|
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
|
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
|
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
|
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
|
|
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
|
|
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
|
elements.get('#keep-week-free-times').checked=true;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
let staged=null;
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+index})),state:()=>({days:staged||[]}),
|
|
stageCapacities:value=>{staged=value;return {sync_pending:true};},flush:async()=>({})};
|
|
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
|
workflow.review(`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Secret meeting\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`);
|
|
await workflow.apply();
|
|
const restored=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)}).availability();
|
|
console.log(JSON.stringify({staged,restored}));
|
|
""")
|
|
|
|
windows = [
|
|
{"start_time": "09:00", "end_time": "10:00"},
|
|
{"start_time": "11:00", "end_time": "17:00"},
|
|
]
|
|
assert result["staged"][0]["free_windows"] == windows
|
|
assert result["restored"][0]["free_windows"] == windows
|
|
assert "Secret meeting" not in json.dumps(result)
|
|
assert "private@example.com" not in json.dumps(result)
|
|
|
|
|
|
def test_calendar_refresh_compares_load_and_applies_overloaded_capacity_with_one_atomic_reflow():
|
|
result = run_import("""
|
|
const elements=new Map();
|
|
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
|
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
|
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
|
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
|
|
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
|
|
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
|
elements.get('#keep-week-free-times').checked=true;
|
|
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
|
const current=dates.map((date,index)=>({plan_date:date,capacity_minutes:480,planned_minutes:index===0?90:index===1?30:0,
|
|
ids:index===0?['one','two']:index===1?['three']:[],estimates:index===0?{one:45,two:45}:index===1?{three:30}:{}}));
|
|
let atomicCalls=0,ordinaryCalls=0,flushes=0,received=null;
|
|
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),review:()=>({days:current}),
|
|
previewCapacityReflow:values=>({blockers:[],unscheduled:[],days:values.map((value,index)=>({...value,
|
|
ids:index===0?['one']:index===1?['two','three']:[],estimates:index===0?{one:45}:index===1?{two:45,three:30}:{}}))}),
|
|
applyCapacityReflow:values=>{atomicCalls+=1;received=values;return {sync_pending:true};},
|
|
stageCapacities:()=>{ordinaryCalls+=1;return {sync_pending:true};},flush:async()=>{flushes+=1;return {};}};
|
|
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
|
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Private meeting name\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T090000\r\nDTEND:20260821T163000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
|
const reviewed=workflow.review(source);const label=elements.get('#apply-week-capacities').textContent;
|
|
const markup=elements.get('#week-capacity-days').innerHTML;await workflow.apply();
|
|
console.log(JSON.stringify({reviewed,label,markup,atomicCalls,ordinaryCalls,flushes,received}));
|
|
""")
|
|
|
|
assert result["reviewed"][0]["capacity_minutes"] == 30
|
|
assert "480 → 30 min available" in result["markup"]
|
|
assert "90 min planned" in result["markup"]
|
|
assert "60 min over refreshed capacity" in result["markup"]
|
|
assert "Moves to Day 2" in result["markup"]
|
|
assert result["label"] == "Apply capacities & reflow"
|
|
assert result["atomicCalls"] == 1
|
|
assert result["ordinaryCalls"] == 0
|
|
assert result["flushes"] == 1
|
|
assert result["received"][0]["free_windows"] == [
|
|
{"start_time": "16:30", "end_time": "17:00"}
|
|
]
|
|
assert "Private meeting name" not in json.dumps(result)
|
|
assert "private@example.com" not in json.dumps(result)
|
|
|
|
|
|
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
|
|
html = (FRONTEND / "index.html").read_text()
|
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert 'id="open-week-capacity-import"' in html
|
|
assert '>Refresh from calendar</button>' in html
|
|
assert '<h2 id="week-capacity-title">Refresh Week Ahead availability</h2>' in html
|
|
assert 'id="week-capacity-import"' in html
|
|
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
|
|
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html
|
|
assert 'id="week-capacity-review"' in html
|
|
assert 'id="keep-week-free-times"' in html
|
|
assert 'id="apply-week-capacities"' in html
|
|
assert '<script src="static/week-calendar-import.js"></script>' in html
|
|
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
|
|
week_plan = (FRONTEND / "week-plan.js").read_text()
|
|
assert "weekCalendarImport.mount" in week_plan
|
|
assert "weekCalendarImport.mount" not in dashboard
|
|
assert "open-week-capacity-import" in MODULE.read_text()
|
|
assert ".week-capacity-import" in css
|
|
assert "overflow-x:hidden" in css
|