285 lines
12 KiB
Python
285 lines
12 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
TODAY_SUMMARY = ROOT / "frontend" / "today-summary.js"
|
|
|
|
|
|
def run_node(script: str):
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_summary_begins_with_exact_worked_and_tomorrow_items_but_keeps_time_private():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const summary = createSummary({{storage,getLogin:()=> ' Timmy '}});
|
|
const snapshot = summary.begin(
|
|
[{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}],
|
|
[{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}],
|
|
);
|
|
process.stdout.write(JSON.stringify({{
|
|
snapshot,
|
|
text:summary.text(),
|
|
keys:[...values.keys()],
|
|
}}));
|
|
"""
|
|
output = run_node(script)
|
|
assert output["snapshot"]["worked"] == [{
|
|
"identity": "issue:acme/mobile:41:",
|
|
"title": "Ship mobile capture",
|
|
"context": "acme/mobile#41",
|
|
"actual_minutes": 42,
|
|
"include": True,
|
|
}]
|
|
assert output["snapshot"]["tomorrow"] == [{
|
|
"identity": "issue:acme/mobile:42:",
|
|
"title": "Review release",
|
|
"context": "acme/mobile#42",
|
|
"include": True,
|
|
}]
|
|
assert output["snapshot"]["include_actuals"] is False
|
|
assert output["text"] == "Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
|
|
assert output["keys"] == ["stackchain.today-summary-draft.v1.timmy"]
|
|
|
|
|
|
def test_summary_privacy_choices_and_note_survive_reload_for_the_same_account():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const options = {{storage,getLogin:()=> 'timmy'}};
|
|
const summary = createSummary(options);
|
|
summary.begin(
|
|
[{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}],
|
|
[{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}],
|
|
);
|
|
summary.includeActuals(true);
|
|
summary.setNote(' Waiting on review ');
|
|
summary.choose('tomorrow', 'issue:acme/mobile:42:', false);
|
|
const restored = createSummary(options);
|
|
process.stdout.write(JSON.stringify({{
|
|
restored:restored.restore(),
|
|
snapshot:restored.snapshot(),
|
|
text:restored.text(),
|
|
}}));
|
|
"""
|
|
output = run_node(script)
|
|
assert output["restored"] is True
|
|
assert output["snapshot"]["include_actuals"] is True
|
|
assert output["snapshot"]["note"] == "Waiting on review"
|
|
assert output["snapshot"]["tomorrow"][0]["include"] is False
|
|
assert output["text"] == "Today\n- Ship mobile capture (42m)\n\nNote\nWaiting on review"
|
|
|
|
|
|
def test_summary_shares_natively_or_once_to_clipboard_and_keeps_canceled_drafts():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const row = [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}];
|
|
const canceled = createSummary({{storage,getLogin:()=> 'timmy',share:async()=>{{const error=new Error('cancel');error.name='AbortError';throw error;}}}});
|
|
canceled.begin(row, []);
|
|
const canceledResult = await canceled.shareSummary();
|
|
const keptAfterCancel = values.has('stackchain.today-summary-draft.v1.timmy');
|
|
const copies = [];
|
|
const fallback = createSummary({{storage,getLogin:()=> 'timmy',copy:async text=>copies.push(text)}});
|
|
fallback.restore();
|
|
const copiedResult = await fallback.shareSummary();
|
|
process.stdout.write(JSON.stringify({{
|
|
canceledResult, keptAfterCancel, copiedResult, copies,
|
|
keptAfterCopy:values.has('stackchain.today-summary-draft.v1.timmy'),
|
|
}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output["canceledResult"] == {"status": "canceled"}
|
|
assert output["keptAfterCancel"] is True
|
|
assert output["copiedResult"] == {"status": "copied"}
|
|
assert output["copies"] == ["Today\n- Ship mobile capture"]
|
|
assert output["keptAfterCopy"] is False
|
|
|
|
|
|
def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const resolved=[];
|
|
const admitted=[];
|
|
const summary=createSummary({{
|
|
storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
|
|
resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
|
|
enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
|
|
}});
|
|
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
|
const parsed=summary.setDestination(' StackChain/Status #42 ');
|
|
const target=await summary.validateDestination();
|
|
const result=await summary.postSummary();
|
|
process.stdout.write(JSON.stringify({{
|
|
parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
|
|
kept:values.has('stackchain.today-summary-draft.v1.timmy'),
|
|
}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output["parsed"] == {
|
|
"repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
|
|
}
|
|
assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
|
|
assert output["target"] == {
|
|
"repository": "stackchain/status", "number": 42, "kind": "issue",
|
|
"title": "Daily status", "state": "open",
|
|
}
|
|
assert output["admitted"] == [{
|
|
"kind": "search-reply", "repository": "stackchain/status", "number": 42,
|
|
"targetKind": "issue", "body": "Today\n- Ship mobile capture",
|
|
"operationId": "summary-op-1",
|
|
}]
|
|
assert output["result"]["status"] == "queued"
|
|
assert output["result"]["durability"] == "background"
|
|
assert output["snapshot"] is None
|
|
assert output["kept"] is False
|
|
|
|
|
|
def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const options={{
|
|
storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
|
|
resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
|
|
enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
|
|
}};
|
|
const summary=createSummary(options);
|
|
summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
|
|
summary.setDestination('acme/mobile#9');
|
|
await summary.validateDestination();
|
|
let error='';
|
|
try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
|
|
const restored=createSummary(options);
|
|
process.stdout.write(JSON.stringify({{
|
|
error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
|
|
}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert output["error"] == "storage unavailable"
|
|
assert output["restored"] is True
|
|
assert output["snapshot"]["operation_id"] == "stable-op"
|
|
assert output["snapshot"]["target"]["kind"] == "pull"
|
|
assert output["text"] == "Today\n- Review release"
|
|
|
|
|
|
def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
let release;
|
|
const gate=new Promise(resolve=>release=resolve);
|
|
const admitted=[];
|
|
const summary=createSummary({{
|
|
getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
|
|
resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
|
|
enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
|
|
}});
|
|
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
|
summary.setDestination('acme/mobile#41');
|
|
await summary.validateDestination();
|
|
const first=summary.postSummary();
|
|
const second=summary.postSummary();
|
|
release();
|
|
const results=await Promise.all([first,second]);
|
|
process.stdout.write(JSON.stringify({{admitted,results}}));
|
|
"""
|
|
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
|
assert len(output["admitted"]) == 1
|
|
assert output["results"][0] == output["results"][1]
|
|
|
|
|
|
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
|
|
from src import main
|
|
|
|
html = main.FRONTEND_BUILD.dashboard_html
|
|
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
|
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
|
recap = (ROOT / "frontend" / "today-recap.js").read_text()
|
|
wrap_up = (ROOT / "frontend" / "today-wrap-up.js").read_text()
|
|
|
|
assert 'id="today-summary-sheet" role="dialog"' in html
|
|
assert 'id="today-summary-worked"' in html
|
|
assert 'id="today-summary-tomorrow"' in html
|
|
assert 'id="today-summary-include-actuals"' in html
|
|
assert 'id="share-today-summary"' in html
|
|
assert 'id="today-summary-destination"' in html
|
|
assert 'id="validate-today-summary-destination"' in html
|
|
assert 'id="post-today-summary"' in html
|
|
assert 'id="today-summary-target"' in html
|
|
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
|
|
assert "setupTodaySummary({" in dashboard
|
|
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
|
|
assert "fetchJson:api" in dashboard
|
|
assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
|
|
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
|
|
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
|
|
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
|
|
assert ".today-summary-actions button { min-height:44px;" in css
|
|
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
|
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
|
|
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
|
|
assert "python3 -m pytest tests/e2e -q" in workflow
|
|
assert (ROOT / "tests/e2e/test_mobile_today_summary_release.py").is_file()
|
|
|
|
|
|
def test_summary_uses_the_human_label_from_recap_feedback_rows():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const summary = createSummary();
|
|
summary.begin([{{
|
|
identity:'issue:acme/mobile:41:', label:'Ship mobile capture',
|
|
context:'acme/mobile#41', actual_minutes:42,
|
|
}}], []);
|
|
process.stdout.write(JSON.stringify({{text:summary.text()}}));
|
|
"""
|
|
assert run_node(script)["text"] == "Today\n- Ship mobile capture"
|
|
|
|
|
|
def test_summary_draft_is_not_visible_after_the_confirmed_account_changes():
|
|
script = f"""
|
|
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let login='timmy';
|
|
const summary=createSummary({{storage,getLogin:()=>login}});
|
|
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Private work',actual_minutes:42}}], []);
|
|
login='alexander';
|
|
process.stdout.write(JSON.stringify({{snapshot:summary.snapshot(),text:summary.text()}}));
|
|
"""
|
|
assert run_node(script) == {"snapshot": None, "text": ""}
|