feat: choose exact Later return times (#369)
This commit is contained in:
1 parent
76bea83537
commit
9441191c18
15 files changed
+390
-20
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.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_exact_local_time_is_validated_and_converted_to_an_instant():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0),
|
||||
}});
|
||||
const valid = picker.parse('2026-08-09T16:30');
|
||||
const past = picker.parse('2026-08-09T13:59');
|
||||
const invalid = picker.parse('2026-02-30T12:00');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
|
||||
past, invalid,
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
|
||||
assert output["past"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a future date and time.",
|
||||
}
|
||||
assert output["invalid"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a valid local date and time.",
|
||||
}
|
||||
|
||||
|
||||
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const states = [];
|
||||
const confirmed = [];
|
||||
let focused = 0;
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
|
||||
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
|
||||
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onState: state => states.push([state.open, state.message || '']),
|
||||
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
|
||||
const invalid = picker.submit('2026-08-09T13:00');
|
||||
const valid = picker.submit('2026-08-09T16:30');
|
||||
picker.submit('2026-08-09T17:30');
|
||||
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["invalid"] is False
|
||||
assert output["valid"] is True
|
||||
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
|
||||
assert output["focused"] == 1
|
||||
assert output["current"] is False
|
||||
assert [False, "Choose a future date and time."] not in output["states"]
|
||||
assert [True, "Choose a future date and time."] in output["states"]
|
||||
assert ["back"] in output["states"]
|
||||
|
||||
|
||||
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const order = [];
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{
|
||||
order.push('picker-back');
|
||||
this.state = {{workRoute:'issue'}};
|
||||
listeners.popstate({{state:this.state}});
|
||||
}},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => () => order.push('detail-close'),
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, null, 'detail');
|
||||
const saved = picker.submit('2026-08-09T16:30');
|
||||
process.stdout.write(JSON.stringify({{saved, order}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
|
||||
|
||||
|
||||
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
let popstate;
|
||||
let confirmations = 0;
|
||||
const history = {{
|
||||
state:null,
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => {{ confirmations += 1; return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}});
|
||||
const first = picker.submit('2026-08-09T16:30');
|
||||
const second = picker.submit('2026-08-09T16:30');
|
||||
popstate();
|
||||
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
@@ -233,5 +233,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v50" in worker
|
||||
assert "stackchain-dashboard-shell-v51" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v50" in worker
|
||||
assert "stackchain-dashboard-shell-v51" in worker
|
||||
@@ -11,6 +11,7 @@ from tests.dashboard_bundle import dashboard
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
@@ -1176,6 +1177,29 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
assert "'Deferred until ' + fmt(until)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-picker.js"></script>' in html
|
||||
assert html.count('<button type="button" data-detail-defer-custom') == 4
|
||||
assert 'data-later-custom' in html
|
||||
assert 'id="later-picker"' in html
|
||||
assert 'role="dialog"' in html
|
||||
assert 'type="datetime-local"' in html
|
||||
assert 'id="later-picker-timezone"' in html
|
||||
assert 'id="later-picker-error"' in html
|
||||
assert 'createLaterPicker({' in html
|
||||
assert "laterPicker.open(item, button, 'detail')" in html
|
||||
assert "laterPicker.open(item, button, 'card')" in html
|
||||
assert "detailDefer.deferUntil(item, until, {" in html
|
||||
assert "laterWork.defer(item, until)" in html
|
||||
assert "fetch(" not in LATER_PICKER.read_text()
|
||||
assert '.later-picker-panel' in html
|
||||
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
|
||||
assert 'env(safe-area-inset-bottom)' in html
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
payload = {
|
||||
"issues": [
|
||||
|
||||
@@ -34,6 +34,14 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
||||
assert "HTTP 503" in text
|
||||
|
||||
|
||||
def test_readme_documents_exact_later_return_times():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "Choose date & time" in text
|
||||
assert "valid future local date and time" in text
|
||||
assert "exact instant" in text
|
||||
|
||||
|
||||
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
|
||||
@@ -105,10 +105,17 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -117,21 +124,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -331,6 +338,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
|
||||
@@ -63,7 +63,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v50" in source
|
||||
assert "stackchain-dashboard-shell-v51" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user