import json
import os
import subprocess
from pathlib import Path

import pytest

from tests.dashboard_bundle import dashboard


@pytest.mark.anyio
async def test_empty_all_work_queue_offers_find_and_create_actions():
    markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
    source = await dashboard()

    assert 'id="empty-work-start"' in markup
    assert 'Ready for something new?' in markup
    assert 'id="empty-work-find"' in markup
    assert 'id="empty-work-create"' in markup
    assert "qs('#empty-work-find').addEventListener('click'" in source
    assert "qs('#find-work').click()" in source
    assert "qs('#empty-work-create').addEventListener('click'" in source
    assert "qs('#new-issue').click()" in source
    assert "emptyWorkStart.hidden = !showEmptyStart" in source


MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.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"
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
FRONTEND_BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"


def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const choose = buildMyWork.filedFollowUpTarget;
const available = typeof choose === 'function';
const quiet = {{key:'repo#1', is_filed:true, has_update:false}};
const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}};
const unrelated = {{key:'repo#3', is_filed:false, has_update:true}};
const results = available ? {{
  first:choose([unrelated, unread, quiet]),
  fallback:choose([unrelated, quiet, unread]),
  empty:choose([unrelated]),
}} : null;
process.stdout.write(JSON.stringify({{available, results}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "available": True,
        "results": {
            "first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}},
            "fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}},
            "empty": None,
        },
    }


def test_completed_filed_outcomes_are_distinct_and_rank_before_open_filings():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"number": 10, "title": "Still delegated", "repository": "stackchain/api",
             "state": "open", "assignees": ["alex"], "work_reasons": ["created_by_me"],
             "updated_at": "2026-08-15T12:00:00Z"},
            {"number": 9, "title": "Outcome ready", "repository": "stackchain/api",
             "state": "closed", "assignees": ["timmy"], "work_reasons": ["created_by_me"],
             "updated_at": "2026-08-14T12:00:00Z"},
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    result = json.loads(completed.stdout)
    assert [item["number"] for item in result] == [9, 10]
    assert result[0]["is_completed"] is True
    assert result[0]["reason"] == "Completed · review outcome"
    assert result[1]["is_completed"] is False
    assert result[1]["reason"] == "Filed by you"


def test_completed_child_resolves_only_a_canonical_reciprocal_parent_task():
    script = f"""
const sheet = require({json.dumps(str(ISSUE_SHEET))});
const child = {{repository:'stackchain/dashboard', number:44,
  url:'https://forge.example/git/stackchain/dashboard/issues/44'}};
const childDetail = {{body:'Delivered.\\n\\nRelated to [stackchain/dashboard#12](https://forge.example/git/stackchain/dashboard/issues/12).'}};
const parentDetail = {{title:'Launch', updated_at:'2026-08-16T10:00:00Z',
  body:'Plan\\r\\n  - [ ] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\\r\\n- [ ] Other'}};
const relationship = sheet.resolveDelegatedParent(child, childDetail, parentDetail);
const unrelated = sheet.resolveDelegatedParent(child,
  {{body:'Related to stackchain/dashboard#12 without a canonical link.'}}, parentDetail);
const mismatch = sheet.resolveDelegatedParent(child, childDetail,
  {{...parentDetail, body:'- [ ] [Different](https://forge.example/git/stackchain/dashboard/issues/45)'}});
const incidental = sheet.resolveDelegatedParent(child, childDetail,
  {{...parentDetail, body:'- [ ] Notes [child](https://forge.example/git/stackchain/dashboard/issues/44)'}});
process.stdout.write(JSON.stringify({{relationship, unrelated, mismatch, incidental}}));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "relationship": {
            "parent": {
                "repository": "stackchain/dashboard",
                "number": 12,
                "url": "https://forge.example/git/stackchain/dashboard/issues/12",
            },
            "taskIndex": 0,
            "alreadyCompleted": False,
            "checklistComplete": False,
        },
        "unrelated": None,
        "mismatch": None,
        "incidental": None,
    }


def test_delegated_parent_completion_changes_only_the_matching_task_marker():
    script = f"""
const complete = require({json.dumps(str(ISSUE_SHEET))}).completeDelegatedParentTask;
const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44';
const body = 'Plan\\r\\n  - [ ] [Ship mobile flow](' + childUrl + ')\\r\\n' +
  '- [ ] Duplicate words\\r\\n- [x] [Already](https://forge.example/issues/9)';
const changed = complete(body, childUrl);
const repeated = complete(changed.body, childUrl);
process.stdout.write(JSON.stringify({{changed, repeated}}));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "changed": {
            "body": "Plan\r\n  - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
            "taskIndex": 0,
            "alreadyCompleted": False,
            "checklistComplete": False,
        },
        "repeated": {
            "body": "Plan\r\n  - [x] [Ship mobile flow](https://forge.example/git/stackchain/dashboard/issues/44)\r\n- [ ] Duplicate words\r\n- [x] [Already](https://forge.example/issues/9)",
            "taskIndex": 0,
            "alreadyCompleted": True,
            "checklistComplete": False,
        },
    }


def test_delegated_parent_completion_reports_when_the_final_task_is_complete():
    script = f"""
const sheet = require({json.dumps(str(ISSUE_SHEET))});
const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44';
const pending = sheet.completeDelegatedParentTask('- [ ] [Ship](' + childUrl + ')', childUrl);
const already = sheet.completeDelegatedParentTask('- [x] [Ship](' + childUrl + ')', childUrl);
process.stdout.write(JSON.stringify({{pending, already}}));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    result = json.loads(completed.stdout)
    assert result["pending"]["checklistComplete"] is True
    assert result["already"]["checklistComplete"] is True


def test_final_delegated_parent_update_closes_before_acknowledgement_can_continue():
    script = f"""
const finish = require({json.dumps(str(ISSUE_SHEET))}).finishDelegatedParentReview;
const childUrl = 'https://forge.example/git/stackchain/dashboard/issues/44';
const calls = [];
(async () => {{
  const changed = await finish({{
    relationship:{{alreadyCompleted:false, checklistComplete:true}},
    parentItem:{{repository:'stackchain/dashboard', number:12}},
    parentDetail:{{title:'Launch', body:'- [ ] [Ship](' + childUrl + ')', updated_at:'rev-1'}},
    childUrl,
    updateContent:async (_item, payload) => calls.push(['update', payload.body]),
    closeParent:async () => calls.push(['close']),
  }});
  const already = await finish({{
    relationship:{{alreadyCompleted:true, checklistComplete:true}},
    parentItem:{{repository:'stackchain/dashboard', number:12}},
    parentDetail:{{title:'Launch', body:'- [x] [Ship](' + childUrl + ')', updated_at:'rev-2'}},
    childUrl,
    updateContent:async () => calls.push(['duplicate-update']),
    closeParent:async () => calls.push(['retry-close']),
  }});
  process.stdout.write(JSON.stringify({{changed, already, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "changed": {"updated": True, "closed": True},
        "already": {"updated": False, "closed": True},
        "calls": [
            ["update", "- [x] [Ship](https://forge.example/git/stackchain/dashboard/issues/44)"],
            ["close"],
            ["retry-close"],
        ],
    }


@pytest.mark.anyio
async def test_completed_filed_review_offers_parent_completion_before_acknowledgement():
    markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
    source = await dashboard()

    assert 'id="complete-parent-and-acknowledge"' in markup
    assert 'Complete parent step &amp; acknowledge' in markup
    assert "resolveDelegatedParent(item, detail, parentDetail)" in source
    handler = source.split("qs('#complete-parent-and-acknowledge').addEventListener('click'", 1)[1].split(
        "function renderCheckSection", 1
    )[0]
    assert "window.confirm" in handler
    assert "await issueController.finishDelegatedParentReview" in handler
    assert handler.index("await issueController.finishDelegatedParentReview") < handler.index("acknowledgeCompletedFiled")
    assert "Complete & close parent · acknowledge" in source


def test_completed_filed_acknowledgement_survives_refresh_until_upstream_update():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key, value) => values.set(key, value),
}};
const create = buildMyWork.createCompletedFiledReview;
if (typeof create !== 'function') {{
  process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
  const items = [
    {{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9,
      is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}},
    {{kind:'issue', key:'stackchain/api#10', repository:'stackchain/api', number:10,
      is_filed:true, is_completed:false, updated_at:'2026-08-15T12:00:00Z'}},
  ];
  const first = create({{storage, getLogin:() => 'timmy'}});
  const before = first.visible(items).map(item => item.number);
  const acknowledged = first.acknowledge(items[0]);
  const after = first.visible(items).map(item => item.number);
  const reloaded = create({{storage, getLogin:() => 'timmy'}});
  const afterRefresh = reloaded.visible(items).map(item => item.number);
  const updated = reloaded.visible([{{...items[0], updated_at:'2026-08-16T12:00:00Z'}}, items[1]])
    .map(item => item.number);
  process.stdout.write(JSON.stringify({{available:true, before, acknowledged, after, afterRefresh, updated}}));
}}
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "available": True,
        "before": [9, 10],
        "acknowledged": True,
        "after": [10],
        "afterRefresh": [10],
        "updated": [9, 10],
    }


def test_completed_filed_review_partitions_acknowledged_history_until_a_new_revision():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key, value) => values.set(key, value),
}};
const create = buildMyWork.createCompletedFiledReview;
const open = {{kind:'issue', key:'stackchain/api#10', repository:'stackchain/api', number:10,
  is_filed:true, is_completed:false, updated_at:'2026-08-15T12:00:00Z'}};
const completed = {{kind:'issue', key:'stackchain/api#9', repository:'stackchain/api', number:9,
  is_filed:true, is_completed:true, updated_at:'2026-08-14T12:00:00Z'}};
const review = create({{storage, getLogin:() => 'timmy'}});
const before = review.partition([open, completed]);
review.acknowledge(completed);
const after = review.partition([open, completed]);
const revised = review.partition([open, {{...completed, updated_at:'2026-08-16T12:00:00Z'}}]);
const shape = value => ({{
  needsReview:value.needsReview.map(item => item.number),
  reviewed:value.reviewed.map(item => item.number),
}});
process.stdout.write(JSON.stringify({{before:shape(before), after:shape(after), revised:shape(revised)}}));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "before": {"needsReview": [10, 9], "reviewed": []},
        "after": {"needsReview": [10], "reviewed": [9]},
        "revised": {"needsReview": [10, 9], "reviewed": []},
    }


def test_completed_filed_review_merges_remote_receipts_and_keeps_local_work_pending():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.has(key) ? values.get(key) : null,
  setItem:(key, value) => values.set(key, value),
}};
const item = (repository, number, updated_at) => ({{
  kind:'issue', key:repository + '#' + number, repository, number,
  is_filed:true, is_completed:true, updated_at,
}});
const local = item('stackchain/api', 9, '2026-08-15T12:00:00Z');
const remote = item('stackchain/web', 4, '2026-08-14T12:00:00Z');
const review = buildMyWork.createCompletedFiledReview({{storage, getLogin:() => 'timmy'}});
review.acknowledge(local);
const pendingBefore = review.pending();
const changed = review.adopt({{receipts:[remote]}});
const visible = review.visible([local, remote]).map(value => value.key);
const pendingAfterRemote = review.pending();
review.adopt({{receipts:[
  {{repository:local.repository, number:local.number, updated_at:local.updated_at}},
  {{repository:remote.repository, number:remote.number, updated_at:remote.updated_at}},
]}});
process.stdout.write(JSON.stringify({{pendingBefore, changed, visible, pendingAfterRemote, pendingFinal:review.pending()}}));
"""
    completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert completed.returncode == 0, completed.stderr
    assert json.loads(completed.stdout) == {
        "pendingBefore": [receipt := {
            "repository": "stackchain/api", "number": 9, "updated_at": "2026-08-15T12:00:00Z"
        }],
        "changed": True,
        "visible": [],
        "pendingAfterRemote": [receipt],
        "pendingFinal": [],
    }


@pytest.mark.anyio
async def test_mobile_filed_exposes_actionable_and_reviewed_views_without_inflating_badge():
    markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
    css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
    source = await dashboard()

    assert 'id="filed-history-tabs"' in markup
    assert 'data-filed-view="needs-review"' in markup
    assert 'data-filed-view="reviewed"' in markup
    assert 'No reviewed outcomes yet' in source
    assert "const actionableMyWork = filedHistoryTabs.prepare(lastMyWork)" in source
    assert "const counts = countMyWork(activeMyWork)" in source
    assert "todayWork.reconcile(actionableMyWork" in source
    assert "selectedFiledView === 'reviewed'" in source
    assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in source.split("function openFiledFollowUp()", 1)[1].split("}", 1)[0]
    assert ".filed-history-tabs" in css
    filed_tabs_rule = css.split(".filed-history-tabs button", 1)[1].split("}", 1)[0]
    assert "min-height:44px" in filed_tabs_rule
    assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css


@pytest.mark.anyio
async def test_completed_filed_sheet_exposes_mobile_acknowledge_and_next_flow():
    markup = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
    css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
    source = await dashboard()

    assert 'id="completed-filed-actions"' in markup
    assert 'id="acknowledge-completed-filed"' in markup
    assert 'Acknowledge &amp; next' in markup
    assert "createCompletedFiledReview({" in source
    assert "lastMyWork = buildMyWork(data)" in source
    assert "filed: 'filed issues'" in source
    handler = source.split("function acknowledgeCompletedFiled()", 1)[1].split(
        "qs('#acknowledge-completed-filed').addEventListener", 1
    )[0]
    assert "completedFiledReview.acknowledge(selectedIssue)" in handler
    assert "syncCompletedFiledReviews()" in source
    assert "api/v1/completed-filed-reviews" in source
    assert "sync pending" in source
    assert "filedFollowUpTarget(completedFiledReview.visible(lastMyWork))" in handler
    assert "openRoutedWork" in handler
    assert ".completed-filed-actions" in css
    action_rule = css.split(".completed-filed-actions", 1)[1].split("}", 1)[0]
    assert "position:fixed" in action_rule
    assert "env(safe-area-inset-bottom)" in action_rule
    assert "min-height:44px" in css.split(".completed-filed-actions", 1)[1]


def test_updates_rank_actionable_work_before_newer_routine_activity():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"number": 1, "title": "Critical incident", "repository": "stackchain/api",
             "labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
            {"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
             "updated_at": "2026-08-05T10:00:00Z"},
            {"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
        ],
        "pull_requests": [
            {"number": 4, "title": "Requested review", "repository": "stackchain/web",
             "labels": [], "assignees": [], "work_reasons": ["review_requested"],
             "updated_at": "2026-08-02T10:00:00Z"},
        ],
        "notifications": [
            {"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
             "subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
            {"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
             "subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
            {"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
             "subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
            {"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
             "subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
            {"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
             "subject_type": "Issue", "title": "Newest routine update",
             "updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
  .filter(item => item.has_update)
  .map(item => ({{title:item.title, update_reason:item.update_reason}}));
process.stdout.write(JSON.stringify(updates));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == [
        {"title": "Critical incident", "update_reason": "Critical"},
        {"title": "Requested review", "update_reason": "Review requested"},
        {"title": "Overdue assignment", "update_reason": "Overdue"},
        {"title": "Assigned follow-up", "update_reason": "Assigned to you"},
        {"title": "Newest routine update", "update_reason": ""},
    ]


def test_update_triage_launch_waits_for_complete_single_flight_discovery():
    script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
let release;
let discoveries = 0;
let opens = 0;
const messages = [];
const launcher = createLauncher({{
  selectUpdates: () => {{}},
  discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
  isUpdatesSelected: () => true,
  hasMore: () => false,
  hasCheckpoint: () => false,
  resume: () => {{ throw new Error('must start'); }},
  start: () => {{ opens += 1; return true; }},
  announce: message => messages.push(message),
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
  const before = {{discoveries, opens, same:first === second}};
  release(true);
  return Promise.all([first, second]).then(results =>
    process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
  );
}});
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "before": {"discoveries": 1, "opens": 0, "same": True},
        "discoveries": 1,
        "opens": 1,
        "results": ["opened", "opened"],
        "messages": ["Checking all unread updates…"],
    }


def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
    script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const messages = [];
let resumed = 0;
const launcher = createLauncher({{
  selectUpdates: () => {{}},
  discover: async () => false,
  isUpdatesSelected: () => true,
  hasMore: () => true,
  hasCheckpoint: () => true,
  resume: () => {{ resumed += 1; return true; }},
  start: () => true,
  announce: message => messages.push(message),
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "result": "incomplete",
        "resumed": 0,
        "messages": [
            "Checking all unread updates…",
            "Updates check paused. Retry to check older unread updates.",
        ],
    }


def test_update_triage_launch_applies_atomic_snapshot_before_starting():
    script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const events = [];
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
const launcher = createLauncher({{
  selectUpdates: () => events.push('selected'),
  discover: async () => snapshot,
  applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
  isUpdatesSelected: () => true,
  hasMore: () => false,
  hasCheckpoint: () => false,
  resume: () => {{ throw new Error('must start'); }},
  start: () => {{ events.push('started'); return true; }},
  announce: () => {{}},
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "result": "opened",
        "events": ["selected", ["applied", [1, 51]], "started"],
    }


def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let items = [{{id:1}}];
let fail = true;
const requested = [];
const pager = buildMyWork.createNotificationPager({{
  load: async page => {{
    requested.push(page);
    if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
    return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
      {{page:3,total:101,has_more:false,items:[{{id:101}}]}};
  }},
  onNotifications: value => {{ items = value; }},
  onPagination: () => {{}},
  onStatus: () => {{}},
}});
pager.reset({{page:1,total:101,has_more:true}});
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
  process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "firstResult": False,
        "secondResult": True,
        "requested": [2, 2, 3],
        "ids": [1, 51, 101],
    }


def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
    script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
let finishDiscovery;
let discoveries = 0;
let opens = 0;
let queue = 'agenda';
const launcher = createLauncher({{
  selectAgenda: () => {{ queue = 'agenda'; }},
  discover: () => {{
    discoveries += 1;
    return new Promise(resolve => {{ finishDiscovery = resolve; }});
  }},
  isAgendaSelected: () => queue === 'agenda',
  hasMore: () => false,
  hasCheckpoint: () => true,
  resume: () => {{ opens += 1; return true; }},
  start: () => {{ throw new Error('must resume'); }},
  announce: () => {{}},
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
  const before = {{discoveries, opens, same:first === second}};
  finishDiscovery(true);
  return Promise.all([first, second]).then(results => {{
    process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
  }});
}});
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "before": {"discoveries": 1, "opens": 0, "same": True},
        "results": ["opened", "opened"],
        "discoveries": 1,
        "opens": 1,
    }


def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
    script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
async function scenario(mode) {{
  let queue = 'agenda';
  let opens = 0;
  const messages = [];
  const launcher = createLauncher({{
    selectAgenda: () => {{ queue = 'agenda'; }},
    discover: async () => {{
      if (mode === 'leave') queue = 'today';
      if (mode === 'throw') throw new Error('offline');
      return mode !== 'incomplete';
    }},
    isAgendaSelected: () => queue === 'agenda',
    hasMore: () => mode === 'incomplete',
    hasCheckpoint: () => true,
    resume: () => {{ opens += 1; return true; }},
    start: () => {{ opens += 1; return true; }},
    announce: message => messages.push(message),
  }});
  return {{result:await launcher.open(), opens, messages}};
}}
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
  process.stdout.write(JSON.stringify(results))
);
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == [
        {"result": "incomplete", "opens": 0, "messages": [
            "Agenda check paused. Retry to check older assigned deadlines."
        ]},
        {"result": "incomplete", "opens": 0, "messages": [
            "Agenda check paused. Retry to check older assigned deadlines."
        ]},
        {"result": "cancelled", "opens": 0, "messages": []},
    ]


def test_protect_today_route_is_an_explicit_agenda_action():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today')));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "kind": "queue", "filter": "agenda", "action": "protect-today"
    }
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"


def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
    script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection({{limit:3}});
selection.start();
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
const result = selection.selectMany([
  {{kind:'issue', repository:'stackchain/api', number:1}},
  {{kind:'pull', repository:'stackchain/web', number:2}},
  {{kind:'issue', repository:'stackchain/app', number:3}},
  {{kind:'issue', repository:'stackchain/overflow', number:4}},
]);
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output == {
        "result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
        "snapshot": {
            "active": True,
            "count": 3,
            "ids": [
                "issue:stackchain/api:1:",
                "pull:stackchain/web:2:",
                "issue:stackchain/app:3:",
            ],
        },
    }


def test_work_selection_clears_matches_without_leaving_selection_mode():
    script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection();
selection.start();
selection.selectMany([
  {{kind:'issue', repository:'stackchain/api', number:1}},
  {{kind:'pull', repository:'stackchain/web', number:2}},
]);
process.stdout.write(JSON.stringify(selection.clear()));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}


@pytest.mark.anyio
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
    html = await dashboard()

    assert 'id="select-matching-work"' in html
    assert 'id="clear-work-selection"' in html
    assert "workSelection.selectMany(visible)" in html
    assert "workSelection.clear()" in html
    assert "matches selected" in html
    assert ".selection-scope-actions button { min-height:44px;" in html
    assert "max-width:100%" in html


@pytest.mark.anyio
async def test_issue_sheet_formats_due_date_as_a_calendar_day_without_local_timestamp_conversion():
    source = await dashboard()

    assert "formatCalendarDueDate(detail.due_date)" in source
    assert "new Date(detail.due_date).toLocaleDateString()" not in source


def test_queue_finder_matches_repository_number_and_title_without_reordering():
    script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
  {{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
  {{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
  {{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
];
process.stdout.write(JSON.stringify({{
  repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
  number:work.findQueueItems(items, '#42').map(item => item.key),
  title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
  clear:work.findQueueItems(items, '   ').map(item => item.key),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "repo": ["stackchain/api#42"],
        "number": ["stackchain/api#42", "other/repo#42"],
        "title": ["stackchain/web#7"],
        "clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
    }


def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon():
    script = f"""
const work = require({json.dumps(str(MY_WORK))});
const now = new Date('2026-08-12T12:00:00');
const items = [
  {{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}},
  {{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}},
  {{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}},
  {{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}},
  {{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}},
  {{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}},
  {{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}},
  {{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group])));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == [
        ["o/r#5", "Overdue"], ["o/r#3", "Today"],
        ["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"],
    ]


def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number():
    script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
  {{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
  {{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
  {{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key)));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]


def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const checkpoint = {{
  read:() => null,
  save:() => {{ throw new Error('Agenda must not save Today'); }},
  clear:() => {{ throw new Error('Agenda must not clear Today'); }},
}};
const active = [
  {{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
  {{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
  {{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
  {{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
];
const opened = [];
let finished = 0;
const session = buildMyWork.createWorkSession({{
  getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
  getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
  onOpen:item => opened.push(item.title), onProgress:() => {{}},
  onFinish:() => {{ finished += 1; }},
}});
const started = session.start();
session.next();
session.next();
const completed = session.next();
process.stdout.write(JSON.stringify({{
  started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "started": True,
        "completed": False,
        "opened": ["Overdue", "Today", "Tomorrow"],
        "finished": 1,
        "active": False,
        "today": "preserve-today",
    }


def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key, value) => values.set(key, value),
  removeItem:key => values.delete(key),
}};
let login = 'timmy';
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
  storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
}});
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
  storage, getLogin:() => login, key:'stackchain.today-session.v1',
}});
let mode = 'agenda';
let items = [
  {{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
  {{kind:'issue',repository:'o/r',number:2,title:'Today'}},
  {{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
];
const opened = [];
const makeSession = () => buildMyWork.createWorkSession({{
  getItems:() => items, getFilter:() => 'all',
  checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
  checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
  onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
}});
const first = makeSession();
first.start();
first.next();
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
const todayWhileRunning = first.checkpointed();
login = 'alexander';
const hiddenFromOtherAccount = makeSession().resumable();
login = 'timmy';
items = items.filter(item => item.number !== 2);
const resumedSession = makeSession();
const resumed = resumedSession.resume();
resumedSession.end();
process.stdout.write(JSON.stringify({{
  saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
  agendaCleared:!values.has('stackchain.agenda-session.v1'),
  today:values.get('stackchain.today-session.v1'),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    state = json.loads(result.stdout)
    assert state["saved"] == {
        "version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
    }
    assert state["todayWhileRunning"] is False
    assert state["hiddenFromOtherAccount"] is False
    assert state["resumed"] is True
    assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
    assert state["agendaCleared"] is True
    assert state["today"] == "preserve-today"


@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
    html = await dashboard()

    assert '<script src="static/agenda-session-launcher.js"></script>' in html
    assert "openAgenda: openAgendaSession" in html
    assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
    assert "discover: completeAgendaIssues" in html
    assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
    assert "key: 'stackchain.agenda-session.v1'" in html
    assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
    assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
    assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
    assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
    assert "window.location.hash = '#/my-work/agenda'" in html


@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
    html = await dashboard()

    assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
    assert "selectedWorkFilter === 'agenda' ? 'all'" in html
    assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
    assert "const sessionItems = workSession.items()" in html
    assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
    assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html


@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
    script = f"""
const work = require({json.dumps(str(MY_WORK))});
const now = new Date(2026, 7, 13, 12, 0, 0);
const built = work({{
  user:{{login:'timmy'}}, notifications:[], pull_requests:[],
  issues:[{{number:1,title:'Ship',repository:'o/r',labels:[],assignees:['timmy'],due_date:'2026-08-13T23:59:59Z'}}],
}}, now);
process.stdout.write(JSON.stringify({{
  dueLabel:built[0].due_label,
  agenda:work.agendaMyWork(built, now).map(item => [item.key, item.agenda_group]),
}}));
"""
    environment = {**os.environ, "TZ": timezone_name}
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True, env=environment
    )
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "dueLabel": "Due today",
        "agenda": [["o/r#1", "Today"]],
    }


def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page():
    script = f"""
const work = require({json.dumps(str(MY_WORK))});
const calls = [];
let failPage = 3;
let items = [{{id:1, title:'first'}}];
let pagination = {{issue:{{page:1,total:4,has_more:true}}}};
const pager = work.createWorkPager({{
  load: async (stream, page) => {{
    calls.push(page);
    await new Promise(resolve => setTimeout(resolve, 5));
    if (page === failPage) throw new Error('offline');
    return {{page,total:4,has_more:page < 4,items:[{{id:page,title:'page '+page}}]}};
  }},
  onItems: (_stream, next) => {{ items = next; }},
  onPagination: next => {{ pagination = next; }},
  onStatus: () => {{}},
}});
pager.reset(pagination);
async function run() {{
  const first = pager.loadAll('issue', () => items);
  const duplicate = pager.loadAll('issue', () => items);
  const failed = await first;
  const samePromise = first === duplicate;
  failPage = 0;
  const retried = await pager.loadAll('issue', () => items);
  process.stdout.write(JSON.stringify({{
    failed, samePromise, retried, calls, ids:items.map(item => item.id), pagination,
  }}));
}}
run();
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "failed": False,
        "samePromise": True,
        "retried": True,
        "calls": [2, 3, 3, 4],
        "ids": [1, 2, 3, 4],
        "pagination": {"issue": {"page": 4, "total": 4, "has_more": False}},
    }


@pytest.mark.anyio
async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_state():
    html = await dashboard()

    assert "workPager.loadAll('issue'" in html
    assert "Checking all assigned deadlines…" in html
    assert "Agenda check paused. Retry to check older assigned deadlines." in html
    assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html


@pytest.mark.anyio
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
    html = await dashboard()

    assert '<form class="queue-finder" id="queue-finder" role="search">' in html
    assert 'id="queue-find-input"' in html
    assert 'aria-label="Find in active work queue"' in html
    assert 'id="clear-queue-find"' in html
    assert 'id="search-older-work"' in html
    assert 'id="queue-find-status" role="status" aria-live="polite"' in html
    assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
    mobile = html.index('@media (max-width: 600px)')
    assert '.queue-finder { position:sticky;' in html[mobile:]
    assert "findQueueItems(queueItems, queueFindQuery)" in html


def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
    script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
function storage(fail = false) {{
  const values = new Map();
  return {{
    getItem:key => values.get(key) || null,
    setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
    removeItem:key => values.delete(key),
  }};
}}
const first = {{kind:'issue',repository:'o/r',number:1}};
const second = {{kind:'pull',repository:'o/r',number:2}};
const third = {{kind:'issue',repository:'o/r',number:3}};
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
const admitted = today.addMany([first, first, second]);
const existing = today.addMany([second, third]);
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{
  admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
        "existing": {"status": "added", "ids": ["issue:o/r:3:"]},
        "full": {"status": "full", "ids": []},
        "ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
        "broken": {"status": "unavailable", "ids": []},
        "brokenIds": [],
    }


def test_work_selection_tracks_cross_kind_items_and_retains_failures():
    script = f"""
const create = require({json.dumps(str(WORK_SELECTION))});
const selection = create({{limit:3}});
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
selection.start(); selection.select(issue); selection.select(pull);
const before = selection.snapshot();
selection.retain([pull]);
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
        "after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
        "issueId": "issue:o/r:1:",
    }


def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
    script = f"""
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
function disclosure(name) {{
  const listeners = {{}};
  const attrs = {{}};
  const summary = {{
    focusCount: 0,
    setAttribute(key, value) {{ attrs[key] = value; }},
    focus() {{ this.focusCount += 1; }},
  }};
  return {{
    name, open: false, dataset: {{}}, summary, attrs,
    querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
    addEventListener(type, listener) {{ listeners[type] = listener; }},
    fire(type, event = {{}}) {{ listeners[type](event); }},
  }};
}}
const first = disclosure('first');
const second = disclosure('second');
const controller = createCardPlanning({{
  querySelectorAll() {{ return [first, second]; }},
}});
controller.wire();
first.open = true;
first.fire('toggle');
second.open = true;
second.fire('toggle');
const escape = {{
  key: 'Escape', prevented: 0, stopped: 0,
  preventDefault() {{ this.prevented += 1; }},
  stopPropagation() {{ this.stopped += 1; }},
}};
second.fire('keydown', escape);
process.stdout.write(JSON.stringify({{
  firstOpen: first.open,
  firstExpanded: first.attrs['aria-expanded'],
  secondOpen: second.open,
  secondExpanded: second.attrs['aria-expanded'],
  secondFocus: second.summary.focusCount,
  prevented: escape.prevented,
  stopped: escape.stopped,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "firstOpen": False,
        "firstExpanded": "false",
        "secondOpen": False,
        "secondExpanded": "false",
        "secondFocus": 1,
        "prevented": 1,
        "stopped": 1,
    }


@pytest.mark.anyio
async def test_my_work_cards_progressively_disclose_planning_on_phones():
    html = await dashboard()
    service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()

    assert '<script src="static/card-planning.js"></script>' in html
    assert "const cardPlanning = createCardPlanning(document);" in html
    assert "cardPlanning.wire();" in html
    assert (
        '<details class="card-planning" data-card-planning>'
        '<summary aria-expanded="false">Plan or defer</summary>'
        '<div class="card-planning-actions">'
    ) in html
    assert '.card-planning > summary { display:none;' in html
    assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
    mobile = html.index('@media (max-width: 600px)')
    assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
    assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
    assert "BASE + 'static/card-planning.js'" in service_worker


def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
    script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
let finishClaim;
const states = [];
const controller = createUpdateOwnership({{
  claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
  addToday: () => 'added',
  onClaimed: item => states.push({{status:'reconciled', item}}),
  onState: state => states.push(state),
}});
controller.open({{
  repository:'stackchain/api', title:'Retry deploy',
  issue:{{number:7, assignees:[], claimable:true}},
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
const first = controller.act();
const second = controller.act();
if (first !== second || claims !== 1) throw new Error('claim was not single-flight');
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
(async () => {{
  await first;
  const added = await controller.act();
  process.stdout.write(JSON.stringify({{claims, added, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["claims"] == 1
    assert output["added"] == "added"
    assert output["states"][0] == {"action": "claim", "busy": False, "message": ""}
    assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"]
    reconciled = next(state for state in output["states"] if state.get("status") == "reconciled")
    assert reconciled["item"]["notification_id"] == 42
    assert reconciled["item"]["repository"] == "stackchain/api"
    assert reconciled["item"]["kind"] == "issue"
    assert output["states"][-1] == {
        "action": "today", "busy": False, "message": "Added to Today."
    }


def test_update_ownership_conflict_removes_stale_action_without_reconciling():
    script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
const states = [];
let reconciled = 0;
const conflict = new Error('server wording'); conflict.status = 409;
const controller = createUpdateOwnership({{
  claim: () => Promise.reject(conflict), addToday: () => 'added',
  onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state),
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
  const result = await controller.act();
  process.stdout.write(JSON.stringify({{result, reconciled, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["result"] == "conflict"
    assert output["reconciled"] == 0
    assert output["states"][-1] == {
        "action": "hidden",
        "busy": False,
        "message": "Someone else claimed or closed this issue. The update is still unread.",
    }


def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
    script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
let starts = 0;
let finishClaim;
const reconciled = [];
const states = [];
const controller = createUpdateOwnership({{
  available: () => true,
  claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
  addToday: () => 'added',
  start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
  recover: () => {{ throw new Error('recovery should not run'); }},
  onClaimed: item => reconciled.push(item),
  onStartState: state => states.push(state),
  onState: () => {{}},
}});
controller.open({{
  repository:'stackchain/api', title:'Retry deploy',
  issue:{{number:7, assignees:[], claimable:true}},
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
const first = controller.start();
const second = controller.start();
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
(async () => {{
  const result = await first;
  process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["result"] == "started"
    assert output["claims"] == 1
    assert output["starts"] == 1
    assert len(output["reconciled"]) == 1
    assert output["reconciled"][0]["notification_id"] == 42
    assert output["reconciled"][0]["has_update"] is True
    assert output["states"][-1] == {
        "action": "hidden",
        "busy": False,
        "message": "Assigned, added to Today, and ready to work. The update is still unread.",
    }


def test_unread_update_start_checks_today_capacity_before_claiming():
    script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
const states = [];
const controller = createUpdateOwnership({{
  available: () => false,
  claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
  addToday: () => 'added', start: () => 'started',
  onStartState: state => states.push(state), onState: () => {{}},
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
  const result = await controller.start();
  process.stdout.write(JSON.stringify({{result, claims, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["result"] == "full"
    assert output["claims"] == 0
    assert output["states"][-1] == {
        "action": "start",
        "busy": False,
        "message": "Today is limited to 5 items. Remove one before taking ownership.",
    }


def test_unread_update_opens_owned_issue_when_today_start_throws():
    script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let recovered = null;
const states = [];
const controller = createUpdateOwnership({{
  available: () => true,
  claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
  addToday: () => 'added',
  start: () => {{ throw new Error('storage failed'); }},
  recover: item => {{ recovered = item; }},
  onStartState: state => states.push(state), onState: () => {{}},
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
  const result = await controller.start();
  process.stdout.write(JSON.stringify({{result, recovered, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["result"] == "recovery"
    assert output["recovered"]["number"] == 7
    assert output["states"][-1] == {
        "action": "hidden",
        "busy": False,
        "message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
    }


@pytest.mark.anyio
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
    html = await dashboard()

    assert '<script src="static/update-ownership.js"></script>' in html
    assert 'id="update-ownership-action"' in html
    assert 'id="update-ownership-start"' in html
    assert '>Take ownership &amp; start</button>' in html
    assert 'hidden aria-describedby="update-sheet-status"' in html
    assert 'const updateOwnership = createUpdateOwnership({' in html
    assert 'available: () => createAndStart.available()' in html
    assert 'const outcome = createAndStart.complete(item)' in html
    assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
    assert 'updateOwnership.open(detail, selectedUpdate)' in html
    assert "qs('#update-ownership-action').addEventListener('click'" in html
    assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
    assert "lastContextSnapshot.issues" in html
    assert "todayWork.add(item)" in html
    assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
    assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
    assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html


def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const inputs = [
  {{kind:'issue',repository:'stackchain/api',number:17}},
  {{kind:'filed',repository:'stackchain/api',number:18}},
  {{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}},
  {{kind:'pull',repository:'stackchain/dashboard',number:42}},
  {{kind:'review',repository:'stackchain/dashboard',number:42}},
  {{kind:'update',notification_id:913}},
];
process.stdout.write(JSON.stringify({{
  paths: inputs.map(routes.serialize),
  parsed: inputs.map(item => routes.parse(routes.serialize(item))),
  invalid: [
    '#/my-work/issue/../../etc/1',
    '#/my-work/issue/stackchain/api/not-a-number',
    '#/my-work/update/-1',
    '#/other/issue/stackchain/api/1',
  ].map(routes.parse),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "paths": [
            "#/my-work/issue/stackchain/api/17",
            "#/my-work/filed/stackchain/api/18",
            "#/my-work/filed/stackchain/api/19",
            "#/my-work/pull/stackchain/dashboard/42",
            "#/my-work/review/stackchain/dashboard/42",
            "#/my-work/update/913",
        ],
        "parsed": [
            {"kind": "issue", "repository": "stackchain/api", "number": 17},
            {"kind": "filed", "repository": "stackchain/api", "number": 18},
            {"kind": "filed", "repository": "stackchain/api", "number": 19},
            {"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
            {"kind": "review", "repository": "stackchain/dashboard", "number": 42},
            {"kind": "update", "notification_id": 913},
        ],
        "invalid": [None, None, None, None],
    }


def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
const calls = [];
const history = {{
  state: null,
  pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
  replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
  back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
}};
const controller = routes.createController({{
  location, history,
  eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
  onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
  onClose() {{ calls.push(['close']); }},
  onInvalid() {{ calls.push(['invalid']); }},
}});
controller.start();
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
controller.close();
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [
        ["open", "issue", "stackchain/api", 17],
        ["back"],
        ["close"],
        ["push", "#/my-work/pull/stackchain/dashboard/9"],
        ["open", "pull", "stackchain/dashboard", 9],
        ["replace", "#/my-work/review/stackchain/dashboard/10"],
        ["open", "review", "stackchain/dashboard", 10],
    ]
    assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"


def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
const calls = [];
const stack = [location.hash];
let cursor = 0;
const history = {{
  pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
  replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
  back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
  location, history,
  eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
  onOpen: item => calls.push(['open', item.number, item.section || null]),
  onSection: (section, options) => calls.push(['section', section, options.restore]),
  onClose() {{}}, onInvalid() {{}},
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
controller.section('reply');
controller.section('actions', {{replace:true}});
history.back();
process.stdout.write(JSON.stringify({{
  calls,
  hash:location.hash,
  valid:[
    routes.parse('#/my-work/issue/stackchain/api/17/reply'),
    routes.parse('#/my-work/update/42/context'),
  ],
  invalid:[
    routes.parse('#/my-work/issue/stackchain/api/17/files'),
    routes.parse('#/my-work/update/42/overview'),
    routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
  ],
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "calls": [
            ["open", 17, None],
            ["push", "#/my-work/issue/stackchain/api/17/reply"],
            ["section", "reply", False],
            ["replace", "#/my-work/issue/stackchain/api/17/actions"],
            ["section", "actions", False],
            ["section", "overview", True],
        ],
        "hash": "#/my-work/issue/stackchain/api/17",
        "valid": [
            {"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
            {"kind": "update", "notification_id": 42, "section": "context"},
        ],
        "invalid": [None, None, None],
    }


def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/updates'}};
const calls = [];
const stack = ['#/my-work/updates'];
let cursor = 0;
const history = {{
  pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
  replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
  back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
  location, history,
  eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
  onQueue: queue => calls.push(['queue', queue]),
  onOpen: item => calls.push(['open', item.notification_id]),
  onClose: () => calls.push(['close']),
  onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([]);
controller.open({{kind:'update', notification_id:42}});
controller.close();
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "calls": [
            ["queue", "update"],
            ["open", 42],
            ["close"],
            ["queue", "update"],
        ],
        "hash": "#/my-work/updates",
        "parsed": {"kind": "queue", "filter": "update"},
    }


def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts'];
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
const listeners = {{}};
const location = {{hash:'#/my-work/agenda'}};
const calls = [];
const stack = ['#/my-work/agenda'];
let cursor = 0;
const history = {{
  pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
  replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
  back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
  location, history,
  eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
  onQueue: queue => calls.push(['queue', queue]),
  onOpen: item => calls.push(['open', item.number]),
  onClose: () => calls.push(['close']),
  onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]);
controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}});
controller.close();
controller.queue('draft');
process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "parsed": [
            ["today", {"kind": "queue", "filter": "today"}],
            ["agenda", {"kind": "queue", "filter": "agenda"}],
            ["attention", {"kind": "queue", "filter": "attention"}],
            ["filed", {"kind": "queue", "filter": "filed"}],
            ["updates", {"kind": "queue", "filter": "update"}],
            ["later", {"kind": "queue", "filter": "later"}],
            ["drafts", {"kind": "queue", "filter": "draft"}],
        ],
        "calls": [
            ["queue", "agenda"],
            ["open", 42],
            ["close"],
            ["queue", "agenda"],
            ["queue", "draft"],
        ],
        "hash": "#/my-work/drafts",
    }


def test_mobile_queue_routes_reject_unknown_or_nested_fragments():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
process.stdout.write(JSON.stringify([
  routes.parse('#/my-work/tomorrow'),
  routes.parse('#/my-work/today/extra'),
  routes.parse('#/my-work/drafts/1'),
]));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == [None, None, None]


def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
const calls = [];
const controller = routes.createController({{
  location,
  history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
  eventTarget: {{addEventListener() {{}}}},
  resolve: async route => {{
    calls.push(['resolve', route.kind, route.repository, route.number]);
    return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
  }},
  onResolving: route => calls.push(['resolving', route.number]),
  onOpen: item => calls.push(['open', item.number, item.title]),
  onClose() {{}},
  onInvalid: () => calls.push(['invalid']),
  onError: () => calls.push(['error']),
}});
(async () => {{
  controller.start();
  controller.setItems([]);
  await new Promise(resolve => setTimeout(resolve, 0));
  process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "calls": [
            ["resolving", 87],
            ["resolve", "issue", "stackchain/api", 87],
            ["open", 87, "Older assigned issue"],
        ],
        "hash": "#/my-work/issue/stackchain/api/87",
    }


def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/update/913'}};
const calls = [];
const unavailable = new Error('gone'); unavailable.unavailable = true;
const controller = routes.createController({{
  location,
  history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
  eventTarget: {{addEventListener() {{}}}},
  resolve: async () => {{ throw unavailable; }},
  onResolving() {{}}, onOpen() {{}}, onClose() {{}},
  onInvalid: () => calls.push('invalid'),
  onError: () => calls.push('error'),
}});
(async () => {{
  controller.start(); controller.setItems([]);
  await new Promise(resolve => setTimeout(resolve, 0));
  process.stdout.write(JSON.stringify(calls));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == ["invalid"]


def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
    script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const calls = [];
(async () => {{
  const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
    share: async payload => calls.push(['native', payload.url]),
  }}, null);
  const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
    writeText: async text => calls.push(['clipboard', text]),
  }});
  process.stdout.write(JSON.stringify({{native, fallback, calls}}));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "native": "shared",
        "fallback": "copied",
        "calls": [
            ["native", "https://forge.example/dashboard/#/my-work/update/9"],
            ["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
        ],
    }


@pytest.mark.anyio
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
    html = await dashboard()

    assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
    assert "const readOnly = issueController.readOnly(item);" in html
    assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
    assert "#issue-sheet.read-only .issue-comment-composer" not in html
    assert "#issue-sheet.read-only .issue-attachment-controls" in html
    assert "#issue-sheet.read-only #issue-planning" in html
    assert "#issue-sheet.read-only #issue-handoff" in html
    assert "#issue-sheet.read-only #release-issue" in html
    assert "qs('#close-issue').hidden = readOnly && !withdrawable" in html
    assert "#issue-sheet.read-only .detail-defer" in html
    assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
    assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html
    assert "readOnly ? 'Filed issue ready · follow-up enabled'" in html
    assert "paintIssueConversation(await issueConversation.loadOlder(), null)" in html
    assert ".issue-comment-composer textarea" in html
    assert ".issue-comment-composer button" in html


@pytest.mark.anyio
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
    html = await dashboard()

    assert '<script src="static/work-route.js"></script>' in html
    assert 'const workRoute = createWorkRoute.createController({' in html
    assert 'workRoute.setItems(lastMyWork);' in html
    assert "api('api/v1/work-route?' + params.toString())" in html
    assert "Loading shared work item…" in html
    assert 'id="retry-work-route"' in html
    assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
    assert '.read-update { min-height:44px; width:100%; display:flex;' in html
    assert 'workRoute.close();' in html
    assert html.count('class="share-work-route"') == 4
    assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
    assert 'Route unavailable · this item is no longer in My Work.' in html
    assert "onQueue: openWorkQueueRoute" in html
    assert "selectWorkQueue(filter, { preserveRoute:true })" in html
    assert "workRoute.queue(filter)" in html
    assert "openDeliveryReceiptRoute" not in html


@pytest.mark.anyio
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
    html = await dashboard()

    assert html.count(
        "onSectionChange:(section, options) => workRoute.section(section, options)"
    ) == 4
    assert "function navigateWorkSection(kind, section)" in html
    assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
    assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
    assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
    assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
    assert "onSection: (section, options) => {" in html
    assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
    assert "await openRoutedWorkSection(item);" in html
    assert "navigateWorkSection(item.kind, item.section);" in html


def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {
                "id": 1,
                "number": 7,
                "title": "Assigned issue",
                "state": "open",
                "repository": "stackchain/mobile",
                "labels": [],
                "assignees": ["timmy"],
                "updated_at": "2026-08-06T12:00:00Z",
                "url": "https://forge.example/mobile/issues/7",
            },
            {
                "id": 2,
                "number": 7,
                "title": "Priority issue",
                "state": "open",
                "repository": "stackchain/api",
                "labels": ["P0"],
                "assignees": [],
                "updated_at": "2026-08-06T11:00:00Z",
                "url": "https://forge.example/api/issues/7",
            },
        ],
        "pull_requests": [
            {
                "id": 3,
                "number": 4,
                "title": "Review PR",
                "state": "open",
                "repository": "stackchain/web",
                "work_reasons": ["review_requested"],
                "updated_at": "2026-08-06T13:00:00Z",
                "url": "https://forge.example/web/pulls/4",
            }
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify(queue));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    queue = json.loads(result.stdout)

    assert [item["title"] for item in queue] == [
        "Priority issue",
        "Review PR",
        "Assigned issue",
    ]
    assert queue[0]["key"] == "stackchain/api#7"
    assert queue[0]["reason"] == "P0 priority"
    assert queue[1]["reason"] == "Needs your review"
    assert queue[1]["is_review"] is True
    assert queue[2]["reason"] == "Assigned to you"


def test_my_work_surfaces_and_ranks_due_issues_after_p0_before_ordinary_work():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"number": 1, "title": "Ordinary", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-07T12:00:00Z"},
            {"number": 2, "title": "Due today", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
            {"number": 3, "title": "Overdue", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
            {"number": 4, "title": "P0 future", "repository": "stackchain/api",
             "labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
        ],
        "pull_requests": [],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason:item.reason,due_label:item.due_label}}))));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == [
        {"title": "P0 future", "reason": "P0 priority", "due_label": "Due Aug 10"},
        {"title": "Overdue", "reason": "Overdue", "due_label": "Overdue"},
        {"title": "Due today", "reason": "Due today", "due_label": "Due today"},
        {"title": "Ordinary", "reason": "Assigned to you"},
    ]


def test_attention_includes_only_assigned_deadline_and_priority_critical_work():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"number": 1, "title": "Assigned P0", "repository": "stackchain/api",
             "labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
            {"number": 2, "title": "Assigned overdue", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
            {"number": 3, "title": "Assigned due today", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
            {"number": 4, "title": "Assigned future", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
            {"number": 5, "title": "Unassigned P0", "repository": "stackchain/api",
             "labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"},
            {"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api",
             "labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"},
        ],
        "pull_requests": [
            {"number": 7, "title": "Updated review", "repository": "stackchain/web",
             "labels": [], "assignees": [], "work_reasons": ["review_requested"]},
        ],
        "notifications": [
            {"id": 70, "number": 7, "repository": "stackchain/web", "unread": True,
             "subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"},
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
const attention = buildMyWork.filterMyWork(items, 'attention');
process.stdout.write(JSON.stringify({{
  titles: attention.map(item => item.title),
  count: buildMyWork.countMyWork(items).attention,
  reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"],
        "count": 4,
        "reasons": {
            "Assigned P0": "P0 priority",
            "Updated review": "Unread update",
            "Assigned overdue": "Overdue",
            "Assigned due today": "Due today",
        },
    }


def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"number": 1, "title": "Older", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
            {"number": 2, "title": "Newer", "repository": "stackchain/api",
             "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"},
        ],
        "pull_requests": [],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']);
process.stdout.write(JSON.stringify({{
  titles: buildMyWork(updated).map(item => item.title),
  labels: updated.issues[0].labels,
  original: original.issues[0].labels,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "titles": ["Older", "Newer"],
        "labels": ["P0"],
        "original": [],
    }


def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot():
    payload = {
        "issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}],
        "pull_requests": [],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.replaceIssueContent(
  original, 'stackchain/api', 17,
  {{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}}
);
process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {
        "updated": {
            "number": 17, "repository": "stackchain/api", "title": "Clarified",
            "body": "New body", "updated_at": "2026-08-07T10:01:00Z",
        },
        "original": {
            "number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body",
        },
    }


def test_issue_sheet_identifies_delegated_filings_as_read_only():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
process.stdout.write(JSON.stringify([
  controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
  controller.readOnly({{kind:'filed',is_filed:true,is_assigned:true,is_completed:true}}),
  controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
  controller.readOnly({{kind:'issue',is_assigned:true}}),
]));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == [True, True, False, False]


def test_issue_sheet_loads_filed_items_through_read_only_access():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const urls = [];
const controller = createIssueSheet({{
  fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null,
}});
Promise.all([
  controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}),
  controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}),
]).then(() => process.stdout.write(JSON.stringify(urls)));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == [
        "api/v1/repos/stackchain/api/issues/17/detail",
        "api/v1/repos/stackchain/api/issues/18/detail?access=filed",
    ]


def test_issue_sheet_withdraws_filed_item_through_author_access_as_single_flight():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let finish;
const controller = createIssueSheet({{
  fetchJson: (url, options={{}}) => {{
    calls.push({{url, method:options.method}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
  storage:null,
}});
const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}};
const first = controller.close(filed);
const duplicate = controller.close(filed);
finish({{number:18,state:'closed',updated_at:'2026-08-15T10:00:00Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, same:first === duplicate, state:results[0].state
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {
        "calls": [{
            "url": "api/v1/repos/stackchain/api/issues/18/close?access=filed",
            "method": "PATCH",
        }],
        "same": True,
        "state": "closed",
    }


@pytest.mark.anyio
async def test_mobile_filed_detail_exposes_only_a_withdrawal_action_for_open_delegated_issue():
    source = await dashboard()
    css = (ISSUE_SHEET.parent / "dashboard.css").read_text()

    assert "const withdrawable = item.is_filed && !item.is_assigned && !item.is_completed && item.state === 'open';" in source
    assert "qs('#close-issue').hidden = readOnly && !withdrawable" in source
    assert "withdrawable ? 'Withdraw issue'" in source
    close_handler = source.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
        "qs('#close-pull-sheet').addEventListener", 1
    )[0]
    assert "Withdraw " in close_handler
    assert "closes the delegated request" in close_handler
    assert "is_completed:true" in close_handler
    assert "#issue-sheet.read-only #issue-planning" in css
    assert ".issue-sheet-actions button" in css and "min-height:44px" in css


@pytest.mark.anyio
async def test_mobile_filed_detail_exposes_change_delegate_flow_for_open_assigned_filing():
    source = await dashboard()
    css = (ISSUE_SHEET.parent / "dashboard.css").read_text()
    html = (ISSUE_SHEET.parent / "index.html").read_text()

    assert "const reassignable = item.is_filed && !item.is_completed && item.state === 'open' &&" in source
    assert "classList.toggle('filed-reassignable', reassignable)" in source
    assert "reassignable ? 'Change delegate' : 'Hand off to teammate'" in source
    assert "issueController.reassign(selectedIssue, recipient)" in source
    assert "Change delegate from @" in source and " to @" in source
    assert "Assigned to ' + recipient" in source
    assert "#issue-sheet.read-only.filed-reassignable #issue-handoff" in css
    assert ".issue-handoff select, .issue-handoff button { min-height:44px; }" in css
    assert 'id="issue-handoff-summary"' in html


def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(ISSUE_SHEET.parent / "conversation.js"))});
const calls = [];
const controller = createIssueSheet({{
  fetchJson: async (url, options={{}}) => {{
    calls.push({{url, method:options.method || 'GET', key:options.headers?.['Idempotency-Key'] || ''}});
    if (options.method === 'POST') return {{id:22, body:'Clarifying detail'}};
    return {{comments:[], page:1, older_page:null, total:0}};
  }},
  storage:null,
  createOperationId:() => 'filed-followup-876',
  createConversationPager,
}});
const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}};
const assigned = {{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}};
const initial = {{comments:[], page:2, older_page:1, total:0}};
Promise.all([
  controller.conversation(filed, initial).loadOlder(),
  controller.comment(filed, 'Clarifying detail'),
  controller.conversation(assigned, initial).loadOlder(),
]).then(() => process.stdout.write(JSON.stringify(calls)));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == [
        {
            "url": "api/v1/repos/stackchain/api/issues/18/comments?page=1&limit=20&access=filed",
            "method": "GET",
            "key": "",
        },
        {
            "url": "api/v1/repos/stackchain/api/issues/18/comments?access=filed",
            "method": "POST",
            "key": "filed-followup-876",
        },
        {
            "url": "api/v1/repos/stackchain/api/issues/17/comments?page=1&limit=20",
            "method": "GET",
            "key": "",
        },
    ]


def test_issue_sheet_reassigns_filed_delegate_with_reviewed_owner_as_single_flight():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let finish;
const controller = createIssueSheet({{
  fetchJson: (url, options={{}}) => {{
    calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
    if (url.includes('handoff-candidates')) return Promise.resolve([
      {{login:'alex',name:'Alexander'}}, {{login:'casey',name:'Casey'}}
    ]);
    return new Promise(resolve => {{ finish = resolve; }});
  }}, storage:null,
}});
const filed = {{repository:'stackchain/api',number:18,is_filed:true,is_assigned:false,assignees:['alex']}};
(async () => {{
  const candidates = await controller.loadHandoffCandidates(filed);
  const first = controller.reassign(filed, 'casey');
  const duplicate = controller.reassign(filed, 'casey');
  finish({{number:18,repository:'stackchain/api',state:'open',assignees:['casey'],recipient:'casey',previous_assignees:['alex']}});
  const results = await Promise.all([first, duplicate]);
  process.stdout.write(JSON.stringify({{calls,candidates,same:first===duplicate,result:results[0]}}));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {
        "calls": [
            {
                "url": "api/v1/repos/stackchain/api/issues/18/handoff-candidates?access=filed",
                "method": "GET", "body": None,
            },
            {
                "url": "api/v1/repos/stackchain/api/issues/18/reassign",
                "method": "PATCH",
                "body": {"recipient": "casey", "expected_assignees": ["alex"]},
            },
        ],
        "candidates": [
            {"login": "alex", "name": "Alexander"},
            {"login": "casey", "name": "Casey"},
        ],
        "same": True,
        "result": {
            "number": 18, "repository": "stackchain/api", "state": "open",
            "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"],
        },
    }


def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let finish;
const controller = createIssueSheet({{
  fetchJson: () => {{
    calls += 1;
    return new Promise(resolve => {{ finish = resolve; }});
  }},
  storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.release(item, 'timmy');
const duplicate = controller.release(item, 'timmy');
finish({{number:17, repository:'stackchain/api', assignees:[], available:true}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, same: first === duplicate, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == 1
    assert output["same"] is True
    assert output["results"][0]["available"] is True


def test_related_issue_draft_reuses_plan_but_resets_issue_specific_work():
    script = f"""
const createIssueSheet = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const related = createIssueSheet.buildRelatedDraft({{
  repository:'stackchain/dashboard', title:'Completed discovery', body:'Filled private report',
  labelIds:[7, 7, 9], milestoneId:4, dueDate:'2026-08-20',
  assignee:'alex', assigneeName:'Alexander', unassigned:false,
  templateId:'bug.yml', templateName:'Bug report', capturedBody:'Original notes',
  blockers:[{{repository:'stackchain/api',number:2}}], estimateMinutes:45,
  completionIntent:'create-and-start', operationId:'already-delivered',
  attachment:{{name:'secret.png'}}, duplicateAcknowledged:true,
}}, {{id:'bug.yml', name:'Bug report', body:'## What happened?\\n\\n## Expected'}});
process.stdout.write(JSON.stringify(related));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    output = json.loads(result.stdout)

    assert output == {
        "repository": "stackchain/dashboard",
        "title": "",
        "body": "## What happened?\n\n## Expected",
        "labelIds": [7, 9],
        "milestoneId": 4,
        "dueDate": "2026-08-20",
        "assignee": "alex",
        "assigneeName": "Alexander",
        "templateId": "bug.yml",
        "templateName": "Bug report",
        "capturedBody": "",
    }


def test_issue_release_rejects_response_that_still_assigns_current_user():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{
  fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null,
}});
controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
  .then(() => process.stdout.write('unexpected'))
  .catch(error => process.stdout.write(error.message));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert result.stdout == "Issue release was not confirmed."


def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
  fetchJson: (url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
  storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.handoff(item, 'alex', 'timmy');
const duplicate = controller.handoff(item, 'alex', 'timmy');
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, same: first === duplicate, results
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["same"] is True
    assert len(output["calls"]) == 1
    assert output["calls"][0]["url"].endswith("/issues/17/handoff")
    assert output["calls"][0]["options"]["method"] == "PATCH"
    assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
    assert output["results"][0]["assignees"] == ["alex"]


def test_issue_handoff_candidates_use_the_assigned_issue_route():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let call;
const controller = createIssueSheet({{
  fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
  storage: null,
}});
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
  .then(result => process.stdout.write(JSON.stringify({{call, result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
    assert output["call"]["options"]["headers"]["Accept"] == "application/json"
    assert output["result"] == [{"login": "alex", "name": "Alexander"}]


def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
  storage:null,
  fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
}});
const item = {{repository:'stackchain/dashboard', number:17}};
const blocker = {{repository:'stackchain/api', number:9}};
const first = controller.updateBlocker(item, blocker, false);
const duplicate = controller.updateBlocker(item, blocker, false);
finish({{repository:item.repository, number:item.number, dependencies_available:true,
  dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
  same:first === duplicate, result:results[0]
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["same"] is True
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
        "method": "POST",
        "body": {"repository": "stackchain/api", "number": 9},
    }]
    assert output["result"]["dependencies"][0]["number"] == 9


def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{
  storage:null,
  fetchJson:async () => ({{dependencies_available:true,
    dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
}});
controller.updateBlocker(
  {{repository:'stackchain/dashboard',number:17}},
  {{repository:'stackchain/api',number:9}}, true
).then(() => process.stdout.write('unexpected'))
 .catch(error => process.stdout.write(error.message));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert result.stdout == "Blocker change was not confirmed."


@pytest.mark.anyio
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
    html = await dashboard()

    assert 'id="manage-issue-blockers"' in html
    assert 'id="issue-blocker-search" type="search"' in html
    assert 'id="issue-blocker-results" role="listbox"' in html
    assert 'id="cancel-issue-blocker"' in html
    assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
    assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
    assert "api('api/v1/search?q='" in html
    assert "result.kind === 'issue' && result.state === 'open'" in html
    assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
    assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
    assert "width:100%; max-width:100%; box-sizing:border-box" in html
    assert "overflow-wrap:anywhere" in html


@pytest.mark.anyio
async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
    html = await dashboard()
    controller = ISSUE_SHEET.read_text()

    assert "issueController.renderTasks(qs('#issue-sheet-body'), detail," in html
    assert "!issueController.readOnly(selectedIssue) && detail.state === 'open'" in html
    assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in html
    assert "issueController.bindTaskToggles({" in html
    assert "container.addEventListener('change', async event =>" in controller
    assert "event.target.closest('input.task-list-toggle')" in controller
    assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
    assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline})" in html
    assert "state.offline ? this.queueTask" in controller
    assert "status.textContent = 'Checklist queued. Pending sync.'" in controller
    assert "buildMyWork.replaceIssueContent" in html
    assert "status.textContent = 'Checklist updated.'" in controller
    assert "restore(state.detail)" in controller
    assert "error.message + ' Checklist was not changed; reload latest or use Edit issue.'" in controller
    assert '.task-list-toggle { min-width:44px; min-height:44px;' in html
    assert '.checklist-pending .task-list-toggle { opacity:.65;' in html


@pytest.mark.anyio
async def test_mobile_issue_detail_adds_a_checklist_step_inline_with_accessible_touch_controls():
    html = await dashboard()

    assert 'id="open-add-checklist-step"' in html
    assert 'id="add-checklist-step-form"' in html
    assert 'id="add-checklist-step" type="text"' in html
    assert 'id="save-checklist-step" type="submit"' in html
    assert 'id="cancel-checklist-step"' in html
    assert 'id="add-checklist-step-status" class="small" aria-live="assertive"' in html
    assert "(selectedIssueOffline ? issueController.queueAddedTask : issueController.addTask).call(" in html
    assert "applyIssueContent(state.item, state.detail, state.offline ? result.detail : result)" in html
    assert "Checklist step queued. Pending sync." in html
    assert "qs('#add-checklist-step').focus()" in html
    assert "qs('#open-add-checklist-step').focus()" in html
    assert '.checklist-add button, .checklist-add input { min-height:44px;' in html
    assert '#issue-sheet.read-only .checklist-add { display:none;' in html
    assert 'grid-template-columns:minmax(0,1fr) auto auto' in html


@pytest.mark.anyio
async def test_mobile_issue_detail_manages_checklist_steps_with_touch_safe_inline_editor():
    html = await dashboard()
    controller = ISSUE_SHEET.read_text()

    assert 'id="checklist-step-editor"' in html
    assert 'id="checklist-step-label" type="text"' in html
    assert 'id="move-checklist-step-earlier"' in html
    assert 'id="move-checklist-step-later"' in html
    assert 'id="remove-checklist-step"' in html
    assert 'id="cancel-checklist-step-edit"' in html
    assert "manageTasks: Boolean(interactive)" in controller
    assert "issueController.bindTaskManagement({" in html
    assert "event.target.closest('button.task-list-manage')" in controller
    assert "this.queueManagedTask : this.manageTask" in controller
    assert "globalThis.confirm('Remove this checklist step?')" in controller
    assert ".task-list-manage { min-width:44px; min-height:44px;" in html
    assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html
    assert "label.focus()" in controller


@pytest.mark.anyio
async def test_mobile_checklist_manager_files_related_issue_and_links_confirmed_child():
    html = await dashboard()
    controller = ISSUE_SHEET.read_text()
    capture_controller = CREATE_ISSUE_SHEET.read_text()

    assert 'id="file-checklist-step-related" type="button">File as related issue</button>' in html
    assert "fileRelated, onFileRelated, remove, cancel" in controller
    assert "onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex" in controller
    assert "relatedTaskDraft" in controller
    assert "linkRelatedTask" in controller
    assert "startChecklistPromotion" in html
    assert "createIssueCapture.createChecklistPromotion({" in html
    assert "checklistPromotion.start(context)" in html
    assert "checklistPromotion?.cancel()" in html
    assert "checklistPromotion?.deliveryContext()" in html
    assert "await issueController.linkRelatedTask(" in capture_controller
    assert "Related issue created, but the parent link needs attention" in capture_controller
    assert ".checklist-step-editor button, .checklist-step-editor input { min-height:44px;" in html


def test_checklist_step_promotion_builds_related_draft_and_revision_checked_parent_link():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const controller = createIssueSheet({{
  storage:null,
  relatedTaskDraft:createIssueCapture.relatedChecklistDraft,
  linkRelatedTask:createIssueCapture.linkChecklistTask,
  fetchJson:async (url, options) => {{
    calls.push({{url, body:JSON.parse(options.body)}});
    return {{number:17,title:'Release',body:options && JSON.parse(options.body).body,updated_at:'new'}};
  }},
}});
const item = {{repository:'stackchain/api',number:17,url:'https://forge.example/stackchain/api/issues/17'}};
const detail = {{title:'Release',body:'Intro\\r\\n  - [x] Build package\\r\\n  - [ ] Ship',updated_at:'old'}};
const draft = controller.relatedTaskDraft(item, detail, 0, '  Build   package  ');
controller.linkRelatedTask(item, detail, 0, {{
  repository:'stackchain/api',number:44,url:'https://forge.example/stackchain/api/issues/44',title:'Build package',
}}).then(result => process.stdout.write(JSON.stringify({{draft,calls,result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["draft"] == {
        "repository": "stackchain/api",
        "title": "Build package",
        "body": "Related to [stackchain/api#17](https://forge.example/stackchain/api/issues/17).",
        "labelIds": [],
    }
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/content",
        "body": {
            "title": "Release",
            "body": "Intro\r\n  - [x] [Build package](https://forge.example/stackchain/api/issues/44)\r\n  - [ ] Ship",
            "expected_updated_at": "old",
        },
    }]
    assert output["result"]["updated_at"] == "new"


@pytest.mark.anyio
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
    html = await dashboard()

    assert "mergeChecklistConflict: mergeChecklistConflict" in html
    assert "const checklistConflict = item.checklist_conflict === true" in html
    assert 'class="draft-review-checklist"' in html
    assert ">Review changes</button>" in html
    assert "const latest = await issueController.load({" in html
    assert "authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin)" in html
    assert "window.confirm('Apply ' + preview.changes.length + ' checklist change'" in html
    assert "await authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
    assert "could not be matched safely" in html
    assert "checklist-conflict.js" in FRONTEND_BUNDLE.read_text()


def test_completed_issue_checklist_offers_close_and_next_during_today():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{storage:null}});
const container = {{querySelectorAll:() => [{{checked:true}}, {{checked:true}}]}};
process.stdout.write(JSON.stringify(controller.checklistCompletion(container, {{
  interactive:true, today:true, offline:false,
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output == {
        "visible": True,
        "status": "Checklist complete",
        "label": "Close & next",
    }


def test_checklist_completion_stays_hidden_when_kept_open():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{storage:null}});
const container = {{querySelectorAll:() => [{{checked:true}}]}};
process.stdout.write(JSON.stringify(controller.checklistCompletion(container, {{
  interactive:true, today:false, offline:false, dismissed:true,
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["visible"] is False


def test_checklist_completion_copy_matches_online_and_offline_close_modes():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{storage:null}});
const container = {{querySelectorAll:() => [{{checked:true}}]}};
process.stdout.write(JSON.stringify({{
  outside:controller.checklistCompletion(container, {{interactive:true,today:false,offline:false}}),
  offlineToday:controller.checklistCompletion(container, {{interactive:true,today:true,offline:true}}),
  offlineOutside:controller.checklistCompletion(container, {{interactive:true,today:false,offline:true}}),
}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["outside"]["label"] == "Close issue"
    assert output["offlineToday"] == {
        "visible": True,
        "status": "Checklist complete · pending sync",
        "label": "Queue close & next",
    }
    assert output["offlineOutside"]["label"] == "Queue issue closure"


@pytest.mark.anyio
async def test_mobile_checklist_completion_bar_reuses_confirmed_close_flow():
    html = await dashboard()

    assert 'id="checklist-completion"' in html
    assert 'id="checklist-completion-status" role="status"' in html
    assert 'id="complete-checklist-issue"' in html
    assert 'id="keep-checklist-issue-open"' in html
    assert "function renderChecklistCompletion(detail)" in html
    assert "issueController.checklistCompletion(qs('#issue-sheet-body')" in html
    assert "qs('#complete-checklist-issue').addEventListener('click', () =>" in html
    assert "qs('#close-issue').click()" in html
    assert "dismissedChecklistBody = selectedIssueDetail?.body" in html
    assert ".checklist-completion { position:fixed;" in html
    assert ".checklist-completion button { min-height:44px;" in html
    assert "padding-bottom:calc(118px + env(safe-area-inset-bottom))" in html


def test_offline_issue_checklist_toggle_waits_for_durable_admission_and_returns_pending_detail():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let admit; const queued = [];
const controller = createIssueSheet({{
  storage:null,
  toggleTask:(body, index, checked) => body.replace(index === 0 ? '[ ]' : '[X]', checked ? '[x]' : '[ ]'),
  enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit = resolve); }},
}});
const item = {{repository:'stackchain/api', number:17}};
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
let settled = false;
const pending = controller.queueTask(item, detail, 0, true).then(result => {{ settled = true; return result; }});
const before = settled;
admit({{item:{{id:'queued-check'}}}});
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["before"] is False
    assert output["queued"] == [{
        "kind": "issue-content", "repository": "stackchain/api", "number": 17,
        "title": "Release", "baseBody": "- [ ] Build\n- [X] Ship",
        "body": "- [x] Build\n- [X] Ship",
        "expectedUpdatedAt": "2026-08-15T10:00:00Z",
    }]
    assert output["result"] == {
        "queued": True,
        "detail": {
            "title": "Release", "body": "- [x] Build\n- [X] Ship",
            "updated_at": "2026-08-15T10:00:00Z", "checklist_pending": True,
        },
    }


def test_offline_issue_checklist_restores_account_bound_pending_body_after_reload():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{storage:null}});
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
const items = [
  {{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'alexander',title:'Wrong',body:'- [x] Wrong',expectedUpdatedAt:'wrong',status:'queued'}},
  {{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'timmy',title:'Release',body:'- [x] Build',expectedUpdatedAt:'old',status:'queued'}},
];
process.stdout.write(JSON.stringify({{
  restored:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'timmy'),
  isolated:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'hou3'),
}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["restored"] == {
        "title": "Release", "body": "- [x] Build", "updated_at": "old",
        "checklist_pending": True,
    }
    assert output["isolated"] == {
        "title": "Release", "body": "- [ ] Build", "updated_at": "old",
    }


def test_pending_offline_checklist_remains_interactive_for_the_next_change():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let options = null; const classes = [];
const container = {{
  classList:{{toggle:(name,present)=>classes.push([name,present])}},
  innerHTML:'',
}};
const controller = createIssueSheet({{
  storage:null,
  renderMarkdown:(body, received)=>{{options=received;return '<label><input class="task-list-toggle"></label>';}}
}});
controller.renderTasks(container, {{body:'- [x] Build\\n- [ ] Test',checklist_pending:true}}, true);
process.stdout.write(JSON.stringify({{options,classes,html:container.innerHTML}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["options"] == {"interactiveTasks": True, "manageTasks": True}
    assert output["classes"] == [["checklist-pending", True]]
    assert "task-list-toggle" in output["html"]


def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
  storage:null,
  toggleTask:(body, index, checked) => body.replace(index === 1 ? '[X]' : '[ ]', checked ? '[x]' : '[ ]'),
  fetchJson:(url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
}});
const item = {{repository:'stackchain/api', number:17}};
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
const first = controller.toggleTask(item, detail, 1, true);
const duplicate = controller.toggleTask(item, detail, 1, true);
finish({{number:17,title:'Release',body:'- [ ] Build\\n- [x] Ship',updated_at:'2026-08-15T10:01:00Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url,body:JSON.parse(call.options.body)}})),
  same:first === duplicate,
  confirmed:results[0],
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["same"] is True
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/content",
        "body": {
            "title": "Release",
            "body": "- [ ] Build\n- [x] Ship",
            "expected_updated_at": "2026-08-15T10:00:00Z",
        },
    }]
    assert output["confirmed"]["updated_at"] == "2026-08-15T10:01:00Z"


def test_issue_sheet_manages_a_step_through_one_revision_guarded_update():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
const controller = createIssueSheet({{
  storage:null,
  manageTask:(body,index,operation) => body + `|${{index}}:${{operation.action}}:${{operation.label || ''}}`,
  fetchJson:(url,options) => {{
    calls.push({{url,body:JSON.parse(options.body)}});
    return Promise.resolve({{number:17,title:'Release',body:'- [ ] Build|0:rename:Compile',updated_at:'new'}});
  }},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
controller.manageTask(item,detail,0,{{action:'rename',label:'Compile'}})
  .then(result => process.stdout.write(JSON.stringify({{calls,result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/content",
        "body": {
            "title": "Release", "body": "- [ ] Build|0:rename:Compile",
            "expected_updated_at": "old",
        },
    }]
    assert output["result"]["updated_at"] == "new"


def test_offline_issue_sheet_manages_a_step_only_after_durable_admission():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let admit; const queued = [];
const controller = createIssueSheet({{
  storage:null,
  manageTask:(body,index,operation) => '- [ ] Compile\\n- [x] Test',
  enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'- [ ] Build\\n- [x] Test',updated_at:'old'}};
let settled = false;
const pending = controller.queueManagedTask(item,detail,0,{{action:'rename',label:'Compile'}})
  .then(result => {{settled=true;return result;}});
const before = settled;
admit({{item:{{id:'manage-step'}}}});
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["before"] is False
    assert output["queued"] == [{
        "kind": "issue-content", "repository": "stackchain/api", "number": 17,
        "title": "Release", "baseBody": "- [ ] Build\n- [x] Test",
        "body": "- [ ] Compile\n- [x] Test", "expectedUpdatedAt": "old",
        "checklistOperation": {"action": "rename", "index": 0, "label": "Compile"},
    }]
    assert output["result"]["detail"]["checklist_pending"] is True
    assert output["result"]["detail"]["body"] == "- [ ] Compile\n- [x] Test"


def test_issue_sheet_adds_a_validated_unchecked_step_without_replacing_existing_content():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
const controller = createIssueSheet({{
  storage:null,
  fetchJson:(url, options) => {{
    calls.push({{url, body:JSON.parse(options.body)}});
    return Promise.resolve({{number:17,title:'Release',body:'Intro\\n\\n- [x] Build\\n- [ ] Verify rollback',updated_at:'new'}});
  }},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'Intro\\n\\n- [x] Build',updated_at:'old'}};
Promise.all([
  controller.addTask(item, detail, '  Verify   rollback  '),
  controller.addTask(item, detail, ' build ').catch(error => ({{error:error.message}})),
  controller.addTask(item, detail, '   ').catch(error => ({{error:error.message}})),
]).then(results => process.stdout.write(JSON.stringify({{calls,results}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/content",
        "body": {
            "title": "Release",
            "body": "Intro\n\n- [x] Build\n- [ ] Verify rollback",
            "expected_updated_at": "old",
        },
    }]
    assert output["results"][0]["body"].endswith("- [ ] Verify rollback")
    assert output["results"][1] == {"error": "That checklist step already exists."}
    assert output["results"][2] == {"error": "Enter a checklist step."}


def test_offline_issue_sheet_adds_a_step_only_after_durable_admission():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let admit; const queued = [];
const controller = createIssueSheet({{
  storage:null,
  enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit=resolve); }},
}});
const item = {{repository:'stackchain/api',number:17}};
const detail = {{title:'Release',body:'Intro',updated_at:'old'}};
let settled = false;
const pending = controller.queueAddedTask(item, detail, 'Verify rollback').then(result => {{settled=true;return result;}});
const before = settled;
admit({{item:{{id:'add-step'}}}});
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["before"] is False
    assert output["queued"] == [{
        "kind": "issue-content", "repository": "stackchain/api", "number": 17,
        "title": "Release", "baseBody": "Intro", "body": "Intro\n- [ ] Verify rollback",
        "expectedUpdatedAt": "old",
    }]
    assert output["result"] == {
        "queued": True,
        "detail": {
            "title": "Release", "body": "Intro\n- [ ] Verify rollback",
            "updated_at": "old", "checklist_pending": True,
        },
    }


def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
  storage,
  fetchJson:(url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
}});
const item = {{repository:'stackchain/api', number:17}};
const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
controller.saveEditDraft(item, draft);
const first = controller.updateContent(item, draft);
const duplicate = controller.updateContent(item, draft);
const during = controller.loadEditDraft(item);
finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
  same:first === duplicate, during, after:controller.loadEditDraft(item), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/content",
        "method": "PATCH",
        "body": {
            "title": "Clarified scope", "body": "Updated body",
            "expected_updated_at": "2026-08-07T10:00:00Z",
        },
    }]
    assert output["same"] is True
    assert output["during"] == {
        "title": "Clarified scope", "body": "Updated body",
        "expectedUpdatedAt": "2026-08-07T10:00:00Z",
    }
    assert output["after"] is None
    assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"


def test_filed_issue_content_edit_uses_author_scoped_endpoint_and_keeps_draft_on_failure():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let call;
const controller = createIssueSheet({{
  storage,
  fetchJson:(url, options) => {{ call={{url,options}}; return Promise.reject(new Error('conflict')); }},
}});
const item = {{repository:'stackchain/api', number:17, is_filed:true, is_assigned:false, state:'open'}};
const draft = {{title:'Clarified filing', body:'Canonical body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
controller.updateContent(item, draft).catch(() => process.stdout.write(JSON.stringify({{
  url:call.url, body:JSON.parse(call.options.body), draft:controller.loadEditDraft(item)
}})));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output == {
        "url": "api/v1/repos/stackchain/api/issues/17/content?access=filed",
        "body": {
            "title": "Clarified filing", "body": "Canonical body",
            "expected_updated_at": "2026-08-07T10:00:00Z",
        },
        "draft": {
            "title": "Clarified filing", "body": "Canonical body",
            "expectedUpdatedAt": "2026-08-07T10:00:00Z",
        },
    }


def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
  storage,
  fetchJson:(url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const duplicate = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const during = controller.loadDueDateDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',due_date:'2026-08-09T23:59:59Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
  same:first === duplicate, during, after:controller.loadDueDateDraft(item), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/due-date",
        "method": "PATCH",
        "body": {"due_date": "2026-08-09T23:59:59Z"},
    }]
    assert output["same"] is True
    assert output["during"] == "2026-08-09T23:59:59Z"
    assert output["after"] is None
    assert output["results"][0]["due_date"] == "2026-08-09T23:59:59Z"


@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_badge():
    html = await dashboard()

    assert 'id="issue-due-date" type="date"' in html
    assert 'id="save-issue-due-date"' in html
    assert 'id="clear-issue-due-date"' in html
    assert 'class="pill due-badge"' in html
    assert '.issue-due-editor input, .issue-due-editor button { min-height:44px;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html


@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
    html = await dashboard()

    assert 'id="issue-handoff-recipient"' in html
    assert 'id="load-issue-handoff"' in html
    assert 'id="confirm-issue-handoff"' in html
    assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
    assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
    assert "issueController.loadHandoffCandidates(selectedIssue)" in html
    assert "issueController.handoff(selectedIssue, recipient" in html


@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
    html = await dashboard()

    assert 'id="create-issue-milestone"' in html
    assert 'id="create-issue-due-date" type="date"' in html
    assert 'id="create-issue-milestone-status" aria-live="polite"' in html
    assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
    assert 'issueFilingMetadata.load(repository, selected)' in html
    assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html


@pytest.mark.anyio
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
    html = await dashboard()

    assert '<link rel="manifest" href="manifest.webmanifest"' in html
    assert "issueCapture.stageSharedContent(sharedLaunch)" in html
    assert 'id="use-shared-content"' in html
    assert 'id="resume-issue-draft"' in html
    assert 'id="shared-content-conflict"' in html
    assert '.shared-content-actions button { min-height:44px;' in html
    assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
    assert "navigator.serviceWorker.register('service-worker.js')" in html


@pytest.mark.anyio
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
    html = await dashboard()

    assert 'id="work-milestone-filter"' in html
    assert '<option value="unplanned">Unplanned</option>' in html
    assert 'id="issue-milestone"' in html
    assert 'id="save-issue-milestone"' in html
    assert 'class="pill milestone-badge"' in html
    assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
    assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
    assert 'buildMyWork.replaceIssueMilestone(' in html


@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
    html = await dashboard()

    body = html.index('id="issue-sheet-body"')
    conversation = html.index('<h2>Full conversation</h2>', body)
    planning = html.index('id="issue-planning"', conversation)
    labels = html.index('id="issue-label-editor"', planning)
    milestone = html.index('id="issue-milestone"', planning)

    assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
    assert '<details class="issue-planning" id="issue-planning">' in html
    assert body < conversation < planning < labels < milestone


def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
  loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
  loadMilestones: async item => {{
    calls.push('milestones:' + item.number);
    if (fail) throw new Error('offline');
    return [{{id:9,title:'RC'}}];
  }},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
  const before = calls.slice();
  let failed = false;
  try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
  fail = false;
  const first = await planning.open(item);
  const second = await planning.open(item);
  process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "before": [],
        "failed": True,
        "calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
        "same": True,
        "first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
    }


@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
    html = await dashboard()
    open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]

    assert 'loadIssueLabelEditor(' not in open_handler
    assert 'loadIssueMilestoneEditor(' not in open_handler
    assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
    assert "qs('#issue-planning').addEventListener('toggle'" in html
    assert 'planningLoader.open(selectedIssue)' in html
    assert 'id="retry-issue-planning"' in html


def test_my_work_reviews_filter_and_summary_are_actionable():
    items = [
        {"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
        {"title": "Assigned PR", "kind": "pull", "is_review": False, "is_assigned": True},
        {"title": "Review PR", "kind": "pull", "is_review": True, "is_assigned": False},
    ]
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = {json.dumps(items)};
process.stdout.write(JSON.stringify({{
  reviews: buildMyWork.filterMyWork(items, 'review'),
  summary: buildMyWork.summarizeMyWork(items),
}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert [item["title"] for item in output["reviews"]] == ["Review PR"]
    assert output["summary"] == "1 review · 2 assigned"


def test_attention_filter_unions_updates_and_reviews_without_double_counting():
    items = [
        {"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
        {"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
        {"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
        {"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
    ]
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = {json.dumps(items)};
process.stdout.write(JSON.stringify({{
  attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
  count: buildMyWork.countMyWork(items).attention,
}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "attention": ["Unread issue", "Requested review", "Updated review"],
        "count": 3,
    }


def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let login = 'timmy';
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
const timmy = store.partition([item]);
login = 'alexander';
const alexander = store.partition([item]);
process.stdout.write(JSON.stringify({{
  deferred,
  timmy:{{active:timmy.active.length,later:timmy.later}},
  alexander:{{active:alexander.active.length,later:alexander.later.length}},
  keys:Array.from(values.keys()),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "deferred": "deferred",
        "timmy": {
            "active": 0,
            "later": [{
                "kind": "pull", "is_review": True, "repository": "stackchain/api",
                "number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
            }],
        },
        "alexander": {"active": 1, "later": 0},
        "keys": ["stackchain.later-work.v1.timmy"],
    }


def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
let login = '';
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const unavailable = createLaterWork({{
  storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
  getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
}});
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
login = 'timmy';
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "noIdentity": "unavailable",
        "invalid": "invalid",
        "failedWrite": "unavailable",
    }


def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const changes = [];
let writes = 0;
let fail = false;
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
  removeItem:key => values.delete(key),
}};
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
const store = createLaterWork({{
  storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
  setTimer:() => 1, clearTimer:() => {{}},
  onChange:(...change) => changes.push(change),
}});
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
const saved = store.partition([first, second]);
const beforeFailure = JSON.stringify(store.read());
fail = true;
const unavailable = store.deferMany([
  {{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
  {{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
], new Date('2026-08-10T09:00:00Z'));
process.stdout.write(JSON.stringify({{
  deferred, unavailable, writes, changes,
  later:saved.later.map(item => [item.notification_id,item.deferred_until]),
  unchanged:beforeFailure === JSON.stringify(store.read()),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "deferred": "deferred",
        "unavailable": "unavailable",
        "writes": 2,
        "changes": [
            ["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
            ["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
        ],
        "later": [
            [91, "2026-08-09T09:00:00.000Z"],
            [92, "2026-08-09T09:00:00.000Z"],
        ],
        "unchanged": True,
    }


def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let clock = new Date('2026-08-08T12:00:00Z');
let scheduled = null;
let wakes = 0;
const store = createLaterWork({{
  storage, getLogin:() => 'timmy', now:() => clock,
  setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
  clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
}});
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
const before = store.partition([kept]);
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
clock = new Date('2026-08-08T13:00:01Z');
scheduled.callback();
const after = store.partition([kept]);
process.stdout.write(JSON.stringify({{
  before:{{active:before.active.length,later:before.later.length}},
  persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
  after:{{active:after.active.map(item => item.title),later:after.later.length}},
  storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "before": {"active": 0, "later": 1},
        "persisted": ["issue:stackchain/api:17:"],
        "delay": 3600000,
        "wakes": 1,
        "after": {"active": ["Kept"], "later": 0},
        "storageEmpty": True,
    }


def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const store = createLaterWork({{
  storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
  setTimer:() => 1, clearTimer:() => {{}},
}});
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
store.defer(second, new Date('2026-08-09T12:00:00Z'));
store.defer(first, new Date('2026-08-09T12:00:00Z'));
store.partition([first], {{pruneMissing:false}});
const visible = store.partition([first, second], {{pruneMissing:false}});
process.stdout.write(JSON.stringify({{
  titles:visible.later.map(item => item.title),
  records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "titles": ["Higher priority", "Lower priority"],
        "records": 2,
    }


def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
    script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const now = new Date('2026-08-08T12:00:00Z');
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const today = store.presetUntil('today');
const tomorrow = store.presetUntil('tomorrow');
store.defer(item, tomorrow);
const removed = store.restore(item);
process.stdout.write(JSON.stringify({{
  today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
  partition:store.partition([item]),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True,
        env={**os.environ, "TZ": "UTC"},
    )

    assert json.loads(result.stdout) == {
        "today": "2026-08-08T16:00:00.000Z",
        "tomorrow": "2026-08-09T09:00:00.000Z",
        "removed": True,
        "partition": {"active": [{
            "kind": "update", "repository": "stackchain/api", "number": 17,
            "notification_id": 91,
        }], "later": []},
    }


def test_starting_deferred_work_moves_it_to_today_before_opening_exact_item():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Resume me'}};
const controller = createLaterAndStart({{
  todayWork: {{
    identity:saved => saved.repository + '#' + saved.number,
    add:saved => {{ calls.push(['today-add', saved.number]); return 'added'; }},
    remove:saved => {{ calls.push(['today-remove', saved.number]); return true; }},
  }},
  todaySync: {{
    enqueue:(action, identity) => {{ calls.push(['today-sync', action, identity]); return true; }},
    flush:() => calls.push(['today-flush']),
  }},
  laterWork: {{restore:saved => {{ calls.push(['later-restore', saved.number]); return true; }}}},
  refresh:() => calls.push(['refresh']),
  warm:() => calls.push(['warm']),
  start:saved => {{ calls.push(['start', saved.number]); return Promise.resolve('opened'); }},
  announce:message => calls.push(['announce', message]),
}});
(async () => {{
  const result = await controller.start(item);
  process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "result": "started",
        "calls": [
            ["today-add", 17],
            ["today-sync", "add", "stackchain/api#17"],
            ["later-restore", 17],
            ["refresh"],
            ["today-flush"],
            ["warm"],
            ["start", 17],
            ["announce", "Moved to Today and opened."],
        ],
    }


def test_starting_deferred_work_keeps_later_when_today_is_full():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const controller = createLaterAndStart({{
  todayWork: {{identity:() => 'issue:x/y:7', add:() => 'full', remove:() => calls.push('remove')}},
  todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
  laterWork: {{restore:() => calls.push('restore')}},
  refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
  start:() => calls.push('start'), announce:message => calls.push(message),
}});
(async () => {{
  const result = await controller.start({{kind:'issue',repository:'x/y',number:7}});
  process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "result": "full",
        "calls": ["Today is limited to 5 items. Remove one, then try Start now again."],
    }


def test_starting_deferred_work_rolls_back_today_when_sync_admission_fails():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'pull',repository:'x/y',number:8}};
const controller = createLaterAndStart({{
  todayWork: {{
    identity:() => 'pull:x/y:8', add:() => {{ calls.push('add'); return 'added'; }},
    remove:() => {{ calls.push('remove'); return true; }},
  }},
  todaySync: {{enqueue:() => {{ calls.push('enqueue'); return false; }}, flush:() => calls.push('flush')}},
  laterWork: {{restore:() => calls.push('restore')}},
  refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
  start:() => calls.push('start'), announce:message => calls.push(message),
}});
(async () => {{
  const result = await controller.start(item);
  process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "result": "sync-unavailable",
        "calls": [
            "add", "enqueue", "remove",
            "Today sync is unavailable. The item remains in Later; try again.",
        ],
    }


def test_starting_deferred_work_rolls_back_today_when_later_cannot_be_removed():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'review',repository:'x/y',number:9}};
const controller = createLaterAndStart({{
  todayWork: {{
    identity:() => 'review:x/y:9', add:() => 'added',
    remove:() => {{ calls.push('remove'); return true; }},
  }},
  todaySync: {{
    enqueue:(action, id) => {{ calls.push(['enqueue', action, id]); return true; }},
    flush:() => calls.push('flush'),
  }},
  laterWork: {{restore:() => false}}, refresh:() => calls.push('refresh'),
  warm:() => calls.push('warm'), start:() => calls.push('start'),
  announce:message => calls.push(message),
}});
(async () => {{
  const result = await controller.start(item);
  process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "result": "later-unavailable",
        "calls": [
            ["enqueue", "add", "review:x/y:9"], "remove",
            ["enqueue", "remove", "review:x/y:9"], "flush",
            "Could not remove this item from Later. Nothing was started; try again.",
        ],
    }


def test_starting_deferred_work_reuses_existing_today_item_without_duplicate_sync():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'issue',repository:'x/y',number:10}};
const controller = createLaterAndStart({{
  todayWork: {{identity:() => 'issue:x/y:10', add:() => 'exists', remove:() => calls.push('remove')}},
  todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
  laterWork: {{restore:() => {{ calls.push('restore'); return true; }}}},
  refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
  start:saved => calls.push(['start', saved.number]), announce:message => calls.push(message),
}});
(async () => {{
  const result = await controller.start(item);
  process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "result": "started",
        "calls": [
            "restore", "refresh", "warm", ["start", 10],
            "Opened the existing Today item.",
        ],
    }


def test_starting_deferred_work_is_single_flight_for_repeated_taps():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
let adds = 0;
let finishStart;
const item = {{kind:'issue',repository:'x/y',number:11}};
const controller = createLaterAndStart({{
  todayWork: {{identity:() => 'issue:x/y:11', add:() => {{ adds += 1; return 'added'; }}, remove:() => true}},
  todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
  refresh:() => {{}}, warm:() => {{}}, announce:() => {{}},
  start:() => new Promise(resolve => {{ finishStart = resolve; }}),
}});
const first = controller.start(item);
const second = controller.start(item);
if (adds !== 1) throw new Error('duplicate admission');
finishStart('opened');
(async () => {{
  const results = await Promise.all([first, second]);
  process.stdout.write(JSON.stringify({{adds,results}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {"adds": 1, "results": ["started", "started"]}


def test_starting_blocked_deferred_work_reports_readiness_gate_without_claiming_open():
    script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const messages = [];
const controller = createLaterAndStart({{
  todayWork: {{identity:() => 'issue:x/y:12', add:() => 'added', remove:() => true}},
  todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
  refresh:() => {{}}, warm:() => {{}}, start:() => Promise.resolve('gated'),
  announce:message => messages.push(message),
}});
(async () => {{
  const result = await controller.start({{kind:'issue',repository:'x/y',number:12}});
  process.stdout.write(JSON.stringify({{result,messages}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "result": "gated",
        "messages": ["Moved to Today. Choose how to handle its blocker before starting."],
    }


def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
    script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
let sessionActive = false;
const controller = createDetailDefer({{
  laterWork: {{
    presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
    defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
  }},
  session: {{ active:() => sessionActive }},
  close:() => calls.push(['close']),
  refresh:() => calls.push(['refresh']),
  focus:() => calls.push(['focus']),
  announce:message => calls.push(['announce', message]),
  formatTime:value => value.toISOString(),
}});
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const outside = controller.defer(item, 'today');
sessionActive = true;
const session = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{outside,session,calls}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "outside": True,
        "session": True,
        "calls": [
            ["defer", "Read first", "2026-08-08T16:00:00.000Z"],
            ["close"],
            ["refresh"],
            ["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
            ["focus"],
            ["defer", "Read first", "2026-08-09T09:00:00.000Z"],
            ["refresh"],
            ["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
        ],
    }


def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
    script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const controller = createDetailDefer({{
  laterWork: {{
    presetUntil:() => new Date('2026-08-08T16:00:00Z'),
    defer:() => 'unavailable',
  }},
  session: {{active:() => false}},
  close:() => calls.push('close'), refresh:() => calls.push('refresh'),
  focus:() => calls.push('focus'), announce:message => calls.push(message),
}});
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "saved": False,
        "calls": ["Could not save Later on this device."],
    }


def test_detail_defer_completes_checkpointed_today_item_after_saving_later():
    script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const controller = createDetailDefer({{
  laterWork: {{
    presetUntil:() => new Date('2026-08-09T09:00:00Z'),
    defer:(saved, until) => {{ calls.push(['defer', saved.title, until.toISOString()]); return 'deferred'; }},
    restore:() => calls.push(['restore']),
  }},
  session: {{active:() => true, checkpointed:saved => saved === item}},
  continueSession:saved => {{ calls.push(['continue', saved.title]); return true; }},
  close:() => calls.push(['close']), refresh:() => calls.push(['refresh']),
  focus:() => calls.push(['focus']), announce:message => calls.push(['announce', message]),
}});
const saved = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "saved": True,
        "calls": [
            ["defer", "Read first", "2026-08-09T09:00:00.000Z"],
            ["continue", "Read first"],
        ],
    }


def test_detail_defer_rolls_back_later_when_today_cannot_be_removed():
    script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const controller = createDetailDefer({{
  laterWork: {{
    presetUntil:() => new Date('2026-08-09T09:00:00Z'),
    defer:() => {{ calls.push('defer'); return 'deferred'; }},
    restore:saved => {{ calls.push(['restore', saved.number]); return true; }},
  }},
  session: {{active:() => true, checkpointed:() => true}},
  continueSession:() => {{ calls.push('continue'); return false; }},
  close:() => calls.push('close'), refresh:() => calls.push('refresh'),
  focus:() => calls.push('focus'), announce:message => calls.push(['announce', message]),
}});
const saved = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "saved": False,
        "calls": [
            "defer",
            "continue",
            ["restore", 17],
            "refresh",
            ["announce", "Could not remove this item from Today, so it was restored from Later."],
        ],
    }


@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
    html = await dashboard()

    assert '<script src="static/detail-defer.js"></script>' in html
    assert html.count('class="detail-defer"') == 4
    assert html.count('data-detail-defer-preset="today"') == 4
    assert html.count('data-detail-defer-preset="tomorrow"') == 4
    assert 'const detailDefer = createDetailDefer({' in html
    assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
    assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
    assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
    assert "fetch(" not in DETAIL_DEFER.read_text()


@pytest.mark.anyio
async def test_checkpointed_detail_defer_completes_today_and_advertises_next_item():
    html = await dashboard()

    assert "continueSession: item => completeTodayItem(item, {" in html
    assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
    assert "advance: () => (mobileFirstTask.completeOutcome(), runTodayTransition('complete'))" in html
    assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
    assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
    assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html


@pytest.mark.anyio
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
    html = await dashboard()

    assert '<script src="static/later-work.js"></script>' in html
    assert 'data-work-filter="later"' in html
    assert 'data-work-count="later"' in html
    assert 'const laterWork = createLaterWork({' in html
    assert 'getLogin: () => planningOwnerLogin' in html
    assert 'laterWork.partition(actionableMyWork,' in html
    assert 'data-later-preset="today"' in html
    assert 'data-later-preset="tomorrow"' in html
    assert 'data-later-restore' in html
    assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
    assert "const result = laterWork.defer(item, until)" in html
    assert 'laterWork.restore(item)' in html
    assert '.later-actions button { min-height:44px;' in html
    assert "'Deferred until ' + fmt(until)" in html


@pytest.mark.anyio
async def test_mobile_later_cards_start_exact_item_in_a_resumable_today_session():
    html = await dashboard()
    service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()

    assert '<script src="static/later-and-start.js"></script>' in html
    assert 'data-later-start' in html
    assert '>Start now</button>' in html
    assert 'aria-label="Deferred work actions"' in html
    assert "const laterAndStart = createLaterAndStart({" in html
    assert "laterAndStart.start(item)" in html
    assert "qs('[data-work-filter=\"today\"]').click();" in html
    assert "todayReadiness.run('start', workSession.items(), item)" in html
    assert "selectedWorkFilter === 'later' ? laterActions" in html
    assert ".later-actions button { min-height:44px; width:100%; }" in html
    assert "BASE + 'static/later-and-start.js'" in service_worker


@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": [
            {"number": 1, "repository": "stackchain/api", "title": "RC issue",
             "milestone": {"id": 9, "title": "August RC"}},
            {"number": 2, "repository": "stackchain/api", "title": "Unplanned",
             "milestone": None},
        ],
        "pull_requests": [
            {"number": 3, "repository": "stackchain/api", "title": "PR"},
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const queue = buildMyWork(original);
const updated = buildMyWork.replaceIssueMilestone(
  original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
);
process.stdout.write(JSON.stringify({{
  rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
  unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
  options: buildMyWork.milestoneLanes(queue),
  updated: updated.issues[1].milestone,
  original: original.issues[1].milestone,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "rc": ["RC issue"],
        "unplanned": ["Unplanned"],
        "options": [{"id": 9, "title": "August RC"}],
        "updated": {"id": 9, "title": "August RC"},
        "original": None,
    }


def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
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 calls = [];
let finish;
const controller = createIssueSheet({{
  storage,
  fetchJson:(url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ finish = resolve; }});
  }},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateMilestone(item, 9);
const duplicate = controller.updateMilestone(item, 9);
const during = controller.loadMilestoneDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
  same:first === duplicate, during, after:controller.loadMilestoneDraft(item), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/17/milestone",
        "method": "PATCH", "body": {"milestone_id": 9},
    }]
    assert output["same"] is True
    assert output["during"] == 9
    assert output["after"] is None
    assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}


def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
    items = [
        {"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
        {"kind": "pull", "repository": "stackchain/web", "number": 2, "title": "Second"},
        {"kind": "issue", "repository": "stackchain/api", "number": 3, "title": "Third"},
    ]
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let items = {json.dumps(items)};
let filter = 'issue';
const opened = [];
const progress = [];
let finished = 0;
const session = buildMyWork.createWorkSession({{
  getItems: () => items,
  getFilter: () => filter,
  onOpen: item => opened.push(item.title),
  onProgress: state => progress.push(state),
  onFinish: () => {{ finished += 1; }},
}});
session.start();
items = [items[2], items[1], items[0]];
session.reconcile();
session.previous();
items = items.filter(item => item.number !== 3);
session.complete();
items = [];
session.complete();
process.stdout.write(JSON.stringify({{opened, progress, finished, active:session.active()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["opened"] == ["First", "Third", "First"]
    assert output["progress"] == [
        {"index": 1, "total": 2, "can_previous": False, "can_next": True},
        {"index": 2, "total": 2, "can_previous": True, "can_next": False},
        {"index": 1, "total": 2, "can_previous": False, "can_next": True},
        {"index": 1, "total": 1, "can_previous": False, "can_next": False},
    ]
    assert output["finished"] == 1
    assert output["active"] is False


def test_mobile_work_session_completion_finishes_when_current_item_is_last():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [{{kind:'pull',repository:'stackchain/api',number:9,is_review:true}}];
let finished = 0;
const session = buildMyWork.createWorkSession({{
  getItems: () => items, getFilter: () => 'review', onOpen: () => {{}},
  onProgress: () => {{}}, onFinish: () => {{ finished += 1; }},
}});
session.start();
session.complete();
process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {"finished": 1, "active": False}


def test_today_timer_restores_wall_clock_time_once_and_excludes_pauses():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
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 now = 1000;
const create = () => createTodayTimer({{
  storage, getLogin:() => 'Timmy', now:() => now,
}});
const first = create();
first.activate('issue:stackchain/dashboard:577:');
now += 10 * 60 * 1000;
const beforeReload = first.snapshot();
const restored = create();
const afterReload = restored.snapshot();
restored.pause();
now += 5 * 60 * 1000;
const whilePaused = restored.snapshot();
restored.resume();
now += 2 * 60 * 1000;
const afterResume = restored.snapshot();
process.stdout.write(JSON.stringify({{
  api:typeof createTodayTimer,
  beforeReload, afterReload, whilePaused, afterResume,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "api": "function",
        "beforeReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
        "afterReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
        "whilePaused": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": False},
        "afterResume": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 720000, "running": True},
    }


def test_today_timer_reconciles_long_away_time_once_and_keeps_short_switches_seamless():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
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';
let now = 0;
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
timer.activate('issue:repo:591:');
now = 60000;
timer.markAway();
now = 4 * 60000;
const short = timer.reconcileInterruption();
now = 5 * 60000;
timer.markAway();
now = 11 * 60000;
const pending = timer.reconcileInterruption();
now = 12 * 60000;
const frozen = timer.snapshot();
const restored = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).pendingInterruption();
const counted = timer.resolveInterruption('count');
const afterCount = timer.snapshot();
const duplicate = timer.resolveInterruption('count');
now = 13 * 60000;
timer.markAway();
now = 19 * 60000;
timer.reconcileInterruption();
const excluded = timer.resolveInterruption('exclude');
const afterExclude = timer.snapshot();
login = 'alexander';
const isolated = timer.pendingInterruption();
process.stdout.write(JSON.stringify({{
  short,pending,frozen,restored,counted,afterCount,duplicate,excluded,afterExclude,isolated,
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "short": None,
        "pending": {
            "identity": "issue:repo:591:",
            "away_ms": 360000,
        },
        "frozen": {
            "identity": "issue:repo:591:",
            "elapsed_ms": 660000,
            "running": False,
        },
        "restored": {
            "identity": "issue:repo:591:",
            "away_ms": 360000,
        },
        "counted": True,
        "afterCount": {
            "identity": "issue:repo:591:",
            "elapsed_ms": 660000,
            "running": True,
        },
        "duplicate": False,
        "excluded": True,
        "afterExclude": {
            "identity": "issue:repo:591:",
            "elapsed_ms": 720000,
            "running": True,
        },
        "isolated": None,
    }


def test_today_timer_pauses_for_attention_and_restores_only_automatic_running_state():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let login = 'timmy';
let now = 0;
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
timer.activate('issue:r:595:');
now = 60000;
const entered = timer.beginAttention();
now = 5 * 60000;
const duplicate = timer.beginAttention();
const frozen = timer.snapshot();
const restored = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}}).attentionInterruption();
now = 11 * 60000;
const returned = timer.returnFromAttention();
now = 12 * 60000;
const resumed = timer.snapshot();
timer.pause();
now = 13 * 60000;
const enteredPaused = timer.beginAttention();
now = 20 * 60000;
const returnedPaused = timer.returnFromAttention();
const stillPaused = timer.snapshot();
login = 'alexander';
const isolated = timer.attentionInterruption();
process.stdout.write(JSON.stringify({{
  entered,duplicate,frozen,restored,returned,resumed,
  enteredPaused,returnedPaused,stillPaused,isolated,
}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "entered": {"identity": "issue:r:595:", "resume": True},
        "duplicate": {"identity": "issue:r:595:", "resume": True},
        "frozen": {"identity": "issue:r:595:", "elapsed_ms": 60000, "running": False},
        "restored": {"identity": "issue:r:595:", "resume": True},
        "returned": {"identity": "issue:r:595:", "resumed": True},
        "resumed": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": True},
        "enteredPaused": {"identity": "issue:r:595:", "resume": False},
        "returnedPaused": {"identity": "issue:r:595:", "resumed": False},
        "stillPaused": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": False},
        "isolated": None,
    }


def test_today_interruption_prompt_restores_and_resolves_the_pending_mobile_decision():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let now = 0;
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
const sheet = {{hidden:true}};
const description = {{textContent:''}};
let resolved = 0;
const prompt = createTodayTimer.createInterruptionPrompt({{
  timer, sheet, description,
  getItemLabel: identity => identity === 'issue:r:7:' ? 'Fix mobile timer' : '',
  onResolved: () => resolved++,
}});
timer.activate('issue:r:7:');
now = 60000;
prompt.background();
now = 7 * 60000;
const opened = prompt.foreground();
const shown = {{hidden:sheet.hidden, text:description.textContent}};
const restored = prompt.restore();
const excluded = prompt.resolve('exclude');
const closed = sheet.hidden;
process.stdout.write(JSON.stringify({{opened,shown,restored,excluded,closed,resolved}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "opened": True,
        "shown": {
            "hidden": False,
            "text": "Fix mobile timer · away for 6 minutes",
        },
        "restored": True,
        "excluded": True,
        "closed": True,
        "resolved": 1,
    }


def test_today_interruption_detection_is_idempotent_and_fails_closed_for_invalid_state():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let now = 0;
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
timer.activate('issue:r:1:');
now = 60000;
timer.markAway();
now = 120000;
timer.markAway();
now = 6 * 60000;
const originalCheckpoint = timer.reconcileInterruption();
timer.resolveInterruption('exclude');
timer.pause();
now = 20 * 60000;
const paused = timer.markAway();
const key = 'stackchain.today-timer.v1.timmy';
const invalid = JSON.parse(values.get(key));
invalid.pending_interruption = {{identity:7,away_ms:'bad'}};
invalid.away_at = now + 1000;
values.set(key, JSON.stringify(invalid));
const malformed = timer.reconcileInterruption();
process.stdout.write(JSON.stringify({{originalCheckpoint,paused,malformed,pending:timer.pendingInterruption()}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "originalCheckpoint": {"identity": "issue:r:1:", "away_ms": 300000},
        "paused": False,
        "malformed": None,
        "pending": None,
    }


def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
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';
let now = 0;
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
timer.activate('issue:repo:1:');
now = 90000;
timer.activate('pull:repo:2:');
now = 120000;
const first = timer.snapshot('issue:repo:1:');
const second = timer.snapshot();
timer.stop();
now = 300000;
const stopped = timer.snapshot();
login = 'alexander';
const other = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).snapshot();
process.stdout.write(JSON.stringify({{first,second,stopped,other}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "first": {"identity": "issue:repo:1:", "elapsed_ms": 90000, "running": False},
        "second": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": True},
        "stopped": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": False},
        "other": {"identity": "", "elapsed_ms": 0, "running": False},
    }


def test_today_timer_totals_elapsed_work_for_live_capacity_without_cross_account_leakage():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let login = 'timmy';
let now = 0;
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
timer.activate('issue:r:1:');
now = 10 * 60000;
timer.activate('issue:r:2:');
now = 25 * 60000;
const timmy = timer.totalElapsed();
login = 'alexander';
const isolated = timer.totalElapsed();
process.stdout.write(JSON.stringify({{timmy,isolated}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {"timmy": 25 * 60000, "isolated": 0}


def test_today_timer_view_renders_live_budget_risk_and_replan_action():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const progress = {{textContent:''}};
const adjust = {{hidden:true}};
const toggle = {{hidden:false,textContent:'',setAttribute(){{}}}};
const snapshot = {{identity:'issue:r:1:',elapsed_ms:45*60000,running:true}};
const view = createTodayTimer.createView({{
  timer:{{snapshot:()=>snapshot,totalElapsed:()=>45*60000}}, isActive:()=>true,
  queryAll:selector => selector.includes('progress') ? [progress] :
    selector.includes('adjust-plan') ? [adjust] :
    selector === '[data-work-session-timer-toggle]' ? [toggle] : [],
  formatEstimate:minutes => minutes + 'm',
  getRunway:() => ({{current_minutes:30,remaining_minutes:90,future_minutes:60,capacity_minutes:100}}),
}});
view.update({{index:1,total:2}});
process.stdout.write(JSON.stringify({{text:progress.textContent,adjustHidden:adjust.hidden}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "text": "Item 1 of 2 · 45:00 / 30m · 15m over estimate · Today projected 5m over capacity",
        "adjustHidden": False,
    }


def test_today_timer_view_keeps_active_item_and_controls_visible_in_mobile_hud():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
class Element {{
  constructor() {{ this.hidden=true; this.textContent=''; this.attributes={{}}; this.listeners={{}}; }}
  setAttribute(name,value) {{ this.attributes[name]=value; }}
  addEventListener(name,callback) {{ this.listeners[name]=callback; }}
  click() {{ this.listeners.click?.(); }}
}}
const hud = new Element();
const title = new Element();
const progress = new Element();
const adjust = new Element();
const toggle = new Element();
let running = true;
const calls = [];
const selectors = {{
  '[data-mobile-today-hud]':[hud],
  '[data-mobile-today-open]':[title],
  '[data-mobile-today-toggle]':[toggle],
  '[data-work-session-progress]':[progress],
  '[data-work-session-adjust-plan]':[adjust],
  '[data-work-session-timer-toggle]':[toggle],
}};
const view = createTodayTimer.createView({{
  timer:{{
    snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:10*60000,running}}),
    totalElapsed:()=>10*60000,
    pause:()=>{{running=false;calls.push('pause');return true;}},
    resume:()=>{{running=true;calls.push('resume');return true;}},
    stop:()=>{{running=false;return true;}},
  }},
  isActive:()=>true,
  queryAll:selector => selectors[selector] || [],
  formatEstimate:minutes => minutes + 'm',
  getItem:identity => identity === 'issue:r:1:' ? {{title:'Ship the mobile Today HUD'}} : null,
  onReopen:identity => calls.push('open:' + identity),
}});
view.update({{index:1,total:2}},{{current_minutes:30,future_minutes:20,capacity_minutes:60}});
const active = {{hidden:hud.hidden,title:title.textContent,progress:progress.textContent,toggle:toggle.textContent}};
title.click();
toggle.click();
const paused = {{toggle:toggle.textContent,pressed:toggle.attributes['aria-pressed']}};
toggle.click();
view.finish();
process.stdout.write(JSON.stringify({{active,paused,calls,finishedHidden:hud.hidden}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "active": {
            "hidden": False,
            "title": "Ship the mobile Today HUD",
            "progress": "Item 1 of 2 · 10:00 / 30m · 40m remaining",
            "toggle": "Pause timer",
        },
        "paused": {"toggle": "Resume timer", "pressed": "true"},
        "calls": ["open:issue:r:1:", "pause", "resume"],
        "finishedHidden": True,
    }


def test_today_timer_view_reopens_current_item_without_resuming_a_paused_timer():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const calls = [];
const timer = {{
  snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:60000,running:false}}),
  totalElapsed:()=>60000,
  activate:identity=>{{calls.push(identity);return true;}},
  stop:()=>true,
}};
const view = createTodayTimer.createView({{
  timer, isActive:()=>true, queryAll:()=>[], formatEstimate:String,
}});
view.open('issue:r:1:', true);
view.open('issue:r:2:', true);
process.stdout.write(JSON.stringify(calls));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == ["issue:r:2:"]


def test_today_budget_replan_pauses_once_and_restores_the_prior_timer_state():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
const calls = [];
let running = true;
const timer = {{
  snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:45*60000,running}}),
  pause:()=>{{calls.push('pause');running=false;return true;}},
  resume:()=>{{calls.push('resume');running=true;return true;}},
}};
const handoff = createTodayTimer.createBudgetReplan({{timer,openPlan:state=>calls.push('open:' + state.identity + ':' + Math.ceil(state.elapsed_ms/60000))}});
const opened = handoff.open();
const duplicate = handoff.open();
const restored = handoff.restore();
running = false;
const pausedOpen = handoff.open();
const pausedRestore = handoff.restore();
process.stdout.write(JSON.stringify({{opened,duplicate,restored,pausedOpen,pausedRestore,calls}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "opened": True,
        "duplicate": False,
        "restored": True,
        "pausedOpen": True,
        "pausedRestore": True,
        "calls": ["pause", "open:issue:r:1::45", "resume", "open:issue:r:1::45"],
    }


def test_today_timer_can_be_initialized_before_operator_identity_is_restored():
    script = f"""
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
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 = '';
let now = 100;
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
login = 'timmy';
const activated = timer.activate('issue:repo:1:');
now = 1100;
process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
"""
    result = subprocess.run(["node", "-e", script], capture_output=True, text=True)

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "activated": True,
        "snapshot": {"identity": "issue:repo:1:", "elapsed_ms": 1000, "running": True},
    }


@pytest.mark.anyio
async def test_today_live_budget_replan_is_wired_into_every_mobile_session_control():
    html = await dashboard()
    dashboard_source = TODAY_TIMER.with_name("dashboard.js").read_text()

    assert html.count('<nav class="work-session-nav" aria-label="Work session" hidden>') == 4
    timer_source = TODAY_TIMER.read_text()
    assert "document.createElement('button')" in timer_source
    assert "button.textContent = 'Adjust remaining plan'" in timer_source
    assert "todayWork.runway(todayMyWork, state.index - 1)" in dashboard_source
    assert 'timer.totalElapsed()' in timer_source
    assert 'createTodayBudgetReplan({' in timer_source
    assert "button.addEventListener('click', () => replan.open())" in timer_source
    assert 'if (sheet.hidden && replan.restore()) render();' in timer_source
    assert '.work-session-nav button { min-height:44px;' in html


@pytest.mark.anyio
async def test_today_timer_is_wired_into_every_mobile_session_control():
    html = await dashboard()
    timer_source = TODAY_TIMER.read_text()
    recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()

    assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
    assert 'createTodayTimer({' in html
    assert 'createTodayTimerView({' in html
    assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
    assert "if (active && current.identity !== identity) timer.activate(identity)" in timer_source
    assert "else if (!active) timer.stop()" in timer_source
    assert "setInterval(timerView.render, 1000)" in recap_source
    assert 'timer.pause()' in timer_source
    assert 'timer.resume()' in timer_source
    assert "elapsed(snapshot.elapsed_ms)" in timer_source
    assert "' / ' + formatEstimate(liveRunway.current_minutes)" in timer_source
    assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html


@pytest.mark.anyio
async def test_dashboard_reconciles_long_today_interruptions_in_a_touch_safe_sheet():
    html = await dashboard()

    assert 'id="today-interruption-sheet"' in html
    assert 'id="today-interruption-description"' in html
    assert 'data-today-interruption="count"' in html
    assert 'data-today-interruption="exclude"' in html
    assert "const interruptionPrompt = createTodayInterruptionPrompt({" in html
    assert "if (document.hidden) interruptionPrompt.background();" in html
    assert "else interruptionPrompt.foreground();" in html
    assert "window.addEventListener('pagehide', () => interruptionPrompt.background());" in html
    assert "interruptionPrompt.restore();" in html
    assert "interruptionPrompt.resolve(button.dataset.todayInterruption)" in html
    assert ".today-interruption-panel { box-sizing:border-box; width:min(620px,100%);" in html
    assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in html
    assert ".today-interruption-actions button { min-height:44px; width:100%; }" in html


def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let saves = 0;
const checkpoint = {{
  read: () => null,
  save: () => {{ saves += 1; return true; }},
  clear: () => true,
}};
const items = [1, 2].map(number => ({{
  kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const progress = [];
const session = buildMyWork.createWorkSession({{
  getItems: () => items, getFilter: () => 'all', checkpoint,
  onOpen: item => opened.push(item.number),
  onProgress: state => progress.push(state.index), onFinish: () => {{}},
}});
session.start();
session.next();
const savesBeforeReopen = saves;
const reopened = session.reopen();
process.stdout.write(JSON.stringify({{
  reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
  todayActive:session.checkpointed(),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], capture_output=True, text=True
    )

    assert result.returncode == 0, result.stderr
    assert json.loads(result.stdout) == {
        "reopened": True,
        "opened": [1, 2, 2],
        "progress": [1, 2, 2],
        "savesBeforeReopen": 2,
        "savesAfterReopen": 2,
        "todayActive": True,
    }


def test_today_session_checkpoint_resumes_current_item_after_reload():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
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 items = [1, 2, 3].map(number => ({{
  kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
  storage, getLogin: () => 'timmy',
}});
const first = buildMyWork.createWorkSession({{
  getItems: () => items, getFilter: () => 'all',
  checkpoint: firstCheckpoint,
  onOpen: item => opened.push('first:' + item.number),
  onProgress: () => {{}}, onFinish: () => {{}},
}});
first.start();
first.next();

const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
  storage, getLogin: () => 'timmy',
}});
const resumed = buildMyWork.createWorkSession({{
  getItems: () => items, getFilter: () => 'all',
  checkpoint: secondCheckpoint,
  onOpen: item => opened.push('resumed:' + item.number),
  onProgress: () => {{}}, onFinish: () => {{}},
}});
const available = resumed.resumable();
const didResume = resumed.resume();
process.stdout.write(JSON.stringify({{
  available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "available": True,
        "didResume": True,
        "opened": ["first:1", "first:2", "resumed:2"],
        "checkpoint": {
            "version": 1,
            "login": "timmy",
            "identity": "issue:stackchain/dashboard:2:",
            "index": 1,
        },
    }


def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let login = 'timmy';
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 checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
const session = buildMyWork.createWorkSession({{
  getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
  getFilter: () => 'all', checkpoint,
  onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
}});
session.start();
login = 'alexander';
const visibleToOtherAccount = session.resumable();
const retainedForOwner = values.size;
login = 'timmy';
session.end();
process.stdout.write(JSON.stringify({{
  visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "visibleToOtherAccount": False,
        "retainedForOwner": 1,
        "active": False,
        "stored": 0,
    }


def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
  version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
}})]]);
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
const session = buildMyWork.createWorkSession({{
  getItems:() => [], getFilter:() => 'all', checkpoint,
  onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
}});
const resumed = session.resume();
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}


def test_today_session_keeps_working_when_checkpoint_storage_fails():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let errors = 0;
const opened = [];
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
  storage:{{
    getItem:() => null,
    setItem:() => {{ throw new Error('quota'); }},
    removeItem:() => {{ throw new Error('quota'); }},
  }},
  getLogin:() => 'timmy',
  onError:() => {{ errors += 1; }},
}});
const session = buildMyWork.createWorkSession({{
  getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
  getFilter:() => 'all', checkpoint,
  onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
}});
session.start();
session.next();
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}


@pytest.mark.anyio
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
    html = await dashboard()

    assert 'id="start-work-session"' in html
    assert html.count('class="work-session-nav"') == 4
    assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
    assert html.count('<button type="button" data-work-session-previous>') == 5
    assert html.count('<button type="button" data-work-session-next>') == 5
    assert 'class="mobile-today-actions"' in html
    assert '.work-session-nav button { min-height:44px;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
    assert 'aria-live="polite" data-work-session-progress' in html


@pytest.mark.anyio
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
    html = await dashboard()
    recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()

    assert 'id="resume-today-session"' in html
    assert 'id="end-today-session"' in html
    assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
    assert 'getLogin: () => confirmedOwnerLogin' in html
    assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
    assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
    assert "qs('#resume-today-session').addEventListener('click'" in html
    assert "qs('#end-today-session').addEventListener('click'" in html
    assert "workSession.resume(item)" in html
    assert "runTodayTransition('resume')" in html
    assert "workSession.end()" in recap_source
    assert '.resume-today-session, .end-today-session { min-height:44px;' in html


@pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
    html = await dashboard()

    assert 'const workSession = createWorkSession({' in html
    assert 'function openWorkSessionItem(item)' in html
    assert "qs('#start-work-session').addEventListener('click'" in html
    assert "document.querySelectorAll('[data-work-session-previous]')" in html
    assert "document.querySelectorAll('[data-work-session-next]')" in html
    assert "runTodayTransition('complete')" in html
    assert 'workSession.reconcile();' in html
    for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
        assert opener in html


@pytest.mark.anyio
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
    html = await dashboard()

    assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
    assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
    close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
        "qs('#close-pull-sheet').addEventListener", 1
    )[0]
    assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
    assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
    assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
        "await runTodayTransition('complete')"
    )
    assert close_handler.index("lastMyWork.filter") < close_handler.index(
        "await runTodayTransition('complete')"
    )


@pytest.mark.anyio
async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition():
    html = await dashboard()

    assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html
    release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split(
        "qs('#load-issue-handoff').addEventListener", 1
    )[0]
    assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler
    assert "await completeOwnershipExitToday(releasing)" in release_handler
    assert "paintMyWork(lastContextSnapshot);" in release_handler
    assert release_handler.index("await issueController.release") < release_handler.index(
        "await completeOwnershipExitToday(releasing)"
    )
    assert release_handler.index("buildMyWork.removeIssue") < release_handler.index(
        "await completeOwnershipExitToday(releasing)"
    )


@pytest.mark.anyio
async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition():
    html = await dashboard()

    assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html
    handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split(
        "qs('#close-issue').addEventListener", 1
    )[0]
    assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler
    assert "await completeOwnershipExitToday(handingOff)" in handoff_handler
    assert "paintMyWork(lastContextSnapshot);" in handoff_handler
    assert handoff_handler.index("await issueController.handoff") < handoff_handler.index(
        "await completeOwnershipExitToday(handingOff)"
    )
    assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index(
        "await completeOwnershipExitToday(handingOff)"
    )


@pytest.mark.anyio
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
    html = await dashboard()

    assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
    merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
        "qs('#keep-update-unread').addEventListener", 1
    )[0]
    assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
    assert "const continuingSession = workSession.active();" in merge_handler
    assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
    assert "merged. Next work item opened." in merge_handler
    assert merge_handler.index("await pullController.merge") < merge_handler.index(
        "await runTodayTransition('complete')"
    )
    assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
        "await runTodayTransition('complete')"
    )


@pytest.mark.anyio
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
    html = await dashboard()
    recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
    merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
        "qs('#keep-update-unread').addEventListener", 1
    )[0]

    assert "else if (!continuingSession)" in merge_handler
    assert "merging.key + ' merged.'" in merge_handler
    assert "else if (continuingSession" not in merge_handler
    assert "onFinish: () =>" in html
    assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source


@pytest.mark.anyio
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
    html = await dashboard()
    merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
        "qs('#keep-update-unread').addEventListener", 1
    )[0]
    success_path, failure_path = merge_handler.split("} catch (error) {", 1)

    assert "lastMyWork = lastMyWork.filter" in success_path
    assert "runTodayTransition('complete')" in success_path
    assert "lastMyWork = lastMyWork.filter" not in failure_path
    assert "workSession.complete()" not in failure_path
    assert "The pull request remains in My Work; refresh and retry." in failure_path
    assert "button.disabled = false;" in failure_path


def test_work_session_can_start_at_a_newly_created_item():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
const opened = [];
const session = buildMyWork.createWorkSession({{
  getItems: () => items,
  getFilter: () => 'all',
  getMilestone: () => '',
  onOpen: item => opened.push(item.number),
  onProgress: () => {{}},
  onFinish: () => {{}},
}});
const started = session.start(items[2]);
process.stdout.write(JSON.stringify({{started, opened}}));
"""

    output = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout
    assert json.loads(output) == {"started": True, "opened": [3]}


def test_my_work_filter_counts_distinguish_prs_from_review_requests():
    items = [
        {"kind": "issue", "is_review": False},
        {"kind": "pull", "is_review": False},
        {"kind": "pull", "is_review": True},
    ]
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
    }


def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
  load: (stream, page) => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{
      stream, page, total: 51, has_more: false,
      items: [
        {{id:1, title:'shared', work_reasons:['review_requested']}},
        {{id:51, title:'older', work_reasons:['review_requested']}}
      ],
    }}); }});
  }},
  onItems: (stream, items) => states.push({{stream, items}}),
  onPagination: pagination => pages.push(pagination),
  onStatus: status => statuses.push(status),
}});
pager.reset({{review:{{page:1,total:51,has_more:true}}}});
const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}];
const first = pager.loadMore('review', existing);
const duplicate = pager.loadMore('review', existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, pages, states, statuses, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert output["states"] == [{
        "stream": "review",
        "items": [
            {"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]},
            {"id": 51, "title": "older", "work_reasons": ["review_requested"]},
        ],
    }]
    assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False}
    assert output["statuses"] == [
        "Loading older review requests…", "51 of 51 review requests loaded."
    ]
    assert output["results"] == [True, False]


def test_work_pager_keeps_items_and_retries_same_page_after_failure():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const requested = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
  load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }},
  onItems: (stream, items) => states.push(items),
  onPagination: () => {{}},
  onStatus: status => statuses.push(status),
}});
pager.reset({{issue:{{page:2,total:125,has_more:true}}}});
pager.loadMore('issue', [{{id:1}}]).then(result =>
  pager.loadMore('issue', [{{id:1}}]).then(retry =>
    process.stdout.write(JSON.stringify({{requested,states,statuses,result,retry}}))
  )
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {
        "requested": [["issue", 3], ["issue", 3]],
        "states": [],
        "statuses": [
            "Loading older issues…", "Could not load older issues. Retry.",
            "Loading older issues…", "Could not load older issues. Retry.",
        ],
        "result": False,
        "retry": False,
    }


def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
    script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
let calls = 0;
let release;
const states = [];
const statuses = [];
const pages = [];
const controller = createFindWork({{
  fetchJson: (url, options) => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{
      id:17,number:7,title:'Available',repository:'stackchain/api',
      state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
    }}); }});
  }},
  onItems: items => states.push(items),
  onPagination: page => pages.push(page),
  onStatus: status => statuses.push(status),
}});
controller.reset({{items:[
  {{id:17,number:7,title:'Available',repository:'stackchain/api'}},
  {{id:18,number:8,title:'Other',repository:'stackchain/web'}}
],page:1,total:2,has_more:false}});
const item = controller.items()[0];
const first = controller.claim(item);
const duplicate = controller.claim(item);
release();
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls,states,statuses,pages,results,remaining:controller.items()
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert [item["number"] for item in output["remaining"]] == [8]
    assert [item["number"] for item in output["states"][-1]] == [8]
    assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
    assert output["pages"][-1] == {"page": 1, "total": 1, "has_more": False}
    assert output["results"][0]["assignees"] == ["timmy"]
    assert output["results"][1]["assignees"] == ["timmy"]


def test_find_work_loads_paginated_results_without_duplicates():
    script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const calls = [];
const states = [];
const pages = [];
const controller = createFindWork({{
  fetchJson: url => {{
    calls.push(url);
    const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
    return Promise.resolve(page === 1 ? {{
      items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
    }} : {{
      items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
      page:2,total:2,has_more:false
    }});
  }},
  onItems: items => states.push(items),
  onPagination: page => pages.push(page),
  onStatus: () => {{}},
}});
controller.load().then(() => controller.loadMore()).then(() =>
  process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [
        "api/v1/available-issues?page=1&facets=true",
        "api/v1/available-issues?page=2&facets=true",
    ]
    assert [item["id"] for item in output["items"]] == [1, 2]
    assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}


def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
    script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const statuses = [];
const states = [];
const responses = [
  {{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
    stale:true,revalidating:true}},
  {{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
    stale:true,refresh_failed:true}},
];
const controller = createFindWork({{
  fetchJson: () => Promise.resolve(responses.shift()),
  onItems: items => states.push(items),
  onPagination: () => {{}},
  onStatus: status => statuses.push(status),
}});
controller.load().then(() => controller.load()).then(() =>
  process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["statuses"] == [
        "Showing saved available work while the catalog refreshes…",
        "Showing saved available work. Catalog refresh failed; retrying shortly.",
    ]
    assert [item["number"] for item in output["items"]] == [1]
    assert len(output["states"]) == 2


def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
    script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const states = [];
const controller = createFindWork({{
  fetchJson: (url, options) => options?.method === 'PATCH'
    ? Promise.resolve({{number:7,repository:'stackchain/api',assignees:['timmy']}})
    : Promise.resolve({{
        items:[
          {{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}},
          {{id:18,number:8,title:'Next',repository:'stackchain/web',body:''}}
        ],page:2,total:2,has_more:false
      }}),
  onItems: items => states.push(items),
  onPagination: () => {{}},
  onStatus: () => {{}},
}});
controller.reset({{
  items:[{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}],
  page:1,total:2,has_more:true
}});
const target = controller.items()[0];
const opened = controller.togglePreview(target);
controller.loadMore().then(() => {{
  const afterPagination = controller.isPreviewed(controller.items()[0]);
  return controller.claim(controller.items()[0]).then(() => process.stdout.write(JSON.stringify({{
    opened, afterPagination, afterClaim:controller.isPreviewed(target), states
  }})));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["opened"] is True
    assert output["afterPagination"] is True
    assert output["afterClaim"] is False
    assert [item["number"] for item in output["states"][-1]] == [8]


@pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
    html = await dashboard()

    assert 'id="work-page-status"' in html
    assert 'id="load-more-work"' in html
    assert '>Load older work<' in html
    assert '.load-more-work { min-height:44px;' in html


@pytest.mark.anyio
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
    html = await dashboard()

    assert 'id="load-more-issue-repositories"' in html
    assert '>Load more repositories<' in html
    assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
    assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
    assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
    assert 'appendIssueRepositories(payload.items)' in html
    assert '.create-issue-repository-more { min-height:44px;' in html


@pytest.mark.anyio
async def test_mobile_issue_capture_requires_explicit_searchable_repository_selection():
    html = await dashboard()

    assert 'id="create-issue-repository-search" type="search" maxlength="80"' in html
    assert 'placeholder="Search accessible repositories"' in html
    assert 'id="create-issue-repository-results" role="listbox"' in html
    assert '<option value="">Choose repository</option>' in html
    assert "issueCapture.searchRepositories(query)" in html
    assert "if (query.length < 2) {\n      issueCapture.searchRepositories(query);" in html
    assert "renderIssueRepositoryResults(state.items)" in html
    assert "selectIssueCaptureRepository(repository)" in html
    assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
    assert '.create-issue-repository-result { min-height:44px;' in html
    assert '.create-issue-repository-picker { min-width:0;' in html


def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
  {{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
  {{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
  {{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
]}};}}}});
const blockers=[
  {{repository:'o/api',number:7,title:'API ready'}},
  {{repository:'o/api',number:7,title:'duplicate'}},
  {{repository:'bad',number:2,title:'invalid'}},
];
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)
    assert output["draft"]["blockers"] == [
        {"repository": "o/api", "number": 7, "title": "API ready"}
    ]
    assert output["found"]["items"] == [
        {"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
    ]
    assert output["calls"] == ["api/v1/search?q=api&limit=20"]


def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map(); const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["owners"] == [{"login": "alex", "name": "Alex"}]
    assert output["draft"]["assignee"] == "alex"
    assert output["draft"]["assigneeName"] == "Alex"
    assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"


def test_issue_capture_persists_and_sends_explicit_no_owner():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}});
(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["draft"]["unassigned"] is True
    assert output["calls"] == [{
        "title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True
    }]


def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
capture.saveDraft({{
  repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
  estimateMinutes:45,completionIntent:'create-and-start',
}});
process.stdout.write(JSON.stringify(capture.loadDraft()));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["estimateMinutes"] == 45
    assert output["completionIntent"] == "create-and-start"


def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map(); const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const fetchJson=async url=>{{calls.push(url);return [{{id:'bug-report',name:'Bug report',about:'Report a defect',title:'[Bug] ',body:'## Steps\\n1.',labels:['bug','unknown']}}];}};
const capture=createIssueCapture({{storage,fetchJson}});
(async()=>{{
  const templates=await capture.loadTemplates('o/r');
  const labels=[{{id:3,name:'bug'}},{{id:4,name:'P1'}}];
  const first=capture.applyTemplate({{repository:'o/r',title:'Camera crashes',body:'Captured on Android',labelIds:[4]}},templates[0],labels);
  const second=capture.applyTemplate(first,{{id:'feature',name:'Feature',body:'## Outcome',labels:[]}},labels);
  capture.saveDraft(second);
  process.stdout.write(JSON.stringify({{templates,first,second,reloaded:capture.loadDraft(),calls}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == ["api/v1/repos/o/r/issue-templates"]
    assert output["first"]["title"] == "Camera crashes"
    assert output["first"]["body"] == "Captured on Android\n\n---\n\n## Steps\n1."
    assert output["first"]["labelIds"] == [4, 3]
    assert output["second"]["body"] == "Captured on Android\n\n---\n\n## Outcome"
    assert output["second"]["templateName"] == "Feature"
    assert output["reloaded"]["templateName"] == "Feature"
    assert output["reloaded"]["capturedBody"] == "Captured on Android"


def test_initial_owner_picker_discards_an_older_same_repository_response():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
let repository='o/a'; const pending=[];
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
  replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
  addEventListener:(name,fn)=>listeners[name]=fn}};}}
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
  createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
  repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
  pending[0]([{{login:'old',name:'Old'}}]);await first;
  process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)
    assert output == ["", "__unassigned__", "new"]


def test_initial_owner_picker_exposes_no_owner_and_disables_start():
    script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[],
  replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},
  appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}}
const owner=select(),status={{textContent:''}},repo={{value:'o/r'}};
const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}};
const documentRef={{querySelector:id=>({{
  '#create-issue-assignee':owner,'#create-issue-assignee-status':status,
  '#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start,
}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}});
picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__';
picker.updateActions(true,false,true);
process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]]
    assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True}
    assert output["submit"]["textContent"] == "Create unassigned"
    assert output["start"]["disabled"] is True
    assert "No owner" in output["start"]["title"]


@pytest.mark.anyio
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
    html = await dashboard()
    assert 'id="create-issue-blocker-search" type="search"' in html
    assert 'id="create-issue-blocker-results" role="listbox"' in html
    assert 'id="create-issue-blocker-selected"' in html
    assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
    assert "issueCapture.searchBlockers(query)" in html
    assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
    assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
    assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
    assert ".create-issue-blocker-result" in html
    assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
    assert "@media(max-width:320px)" in html


@pytest.mark.anyio
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
    html = await dashboard()
    assert 'id="create-issue-assignee"' in html
    assert '<option value="">Me</option>' in html
    assert '<option value="__unassigned__">No owner</option>' in html
    assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
    assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
    assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
    assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
    assert "issueOwnerPicker.updateActions(" in html
    assert '.create-issue-owner select { min-height:44px;' in html


@pytest.mark.anyio
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
    html = await dashboard()
    source = CREATE_ISSUE_SHEET.read_text()
    assert 'id="create-issue-template"' in html
    assert '<option value="">Blank issue</option>' in html
    assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
    assert 'templatePicker.render(repository, metadata.templates' in source
    assert 'issueCapture.bindTemplatePicker(' in html
    assert 'id="issue-filing-review-template"' in html


@pytest.mark.anyio
async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loader():
    html = await dashboard()
    source = CREATE_ISSUE_SHEET.read_text()

    assert "async function loadIssueFilingMetadata(repository" in html
    assert "issueCapture.bindFilingMetadata(" in html
    assert "issueFilingMetadata.load(repository, selected)" in html
    assert "const metadata = await loadFilingMetadata(repository)" in source
    assert "renderLabels(repository, metadata.labels" in source
    assert "renderMilestones(repository, metadata.milestones" in source
    assert "templatePicker.render(repository, metadata.templates" in source
    assert html.count("loadIssueFilingMetadata(") == 5
    assert "loadIssueLabels(repository);\n    loadIssueMilestones(repository);" not in html
    assert "loadIssueLabels(event.target.value).then" not in html
    assert "loadIssueLabels(qs('#create-issue-repository').value" not in html


def test_issue_capture_loads_all_filing_metadata_in_one_section_isolated_request():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
  storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
  fetchJson:url=>{{
    calls.push(url);
    return Promise.resolve({{
      labels:{{available:true,items:[{{id:3,name:'P0'}}]}},
      milestones:{{available:false,items:[],error:'Milestones could not be loaded.'}},
      templates:{{available:true,items:[{{id:'bug',name:'Bug report'}}]}},
    }});
  }},
}});
capture.loadFilingMetadata('stackchain/api').then(result=>
  process.stdout.write(JSON.stringify({{calls,result}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output == {
        "calls": ["api/v1/repos/stackchain/api/issue-filing-metadata"],
        "result": {
            "status": "ready",
            "repository": "stackchain/api",
            "labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
            "milestones": {
                "available": False,
                "items": [],
                "error": "Milestones could not be loaded.",
            },
            "templates": {
                "available": True,
                "items": [{"id": "bug", "name": "Bug report"}],
            },
        },
    }


def test_issue_capture_rejects_filing_metadata_from_a_stale_repository_request():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const releases = {{}};
const capture = createIssueCapture({{
  storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
  fetchJson:url=>new Promise(resolve=>{{ releases[url] = resolve; }}),
}});
const first = capture.loadFilingMetadata('stackchain/old');
const second = capture.loadFilingMetadata('stackchain/current');
const payload = {{
  labels:{{available:true,items:[]}}, milestones:{{available:true,items:[]}},
  templates:{{available:true,items:[]}},
}};
releases['api/v1/repos/stackchain/current/issue-filing-metadata'](payload);
Promise.resolve().then(()=>{{
  releases['api/v1/repos/stackchain/old/issue-filing-metadata'](payload);
  return Promise.all([first, second]);
}}).then(results=>process.stdout.write(JSON.stringify(results)));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == [
        {"status": "stale", "repository": "stackchain/old"},
        {
            "status": "ready", "repository": "stackchain/current",
            "labels": {"available": True, "items": []},
            "milestones": {"available": True, "items": []},
            "templates": {"available": True, "items": []},
        },
    ]


@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
    html = await dashboard()

    assert 'id="edit-issue-content"' in html
    assert 'id="issue-edit-form"' in html
    assert 'id="issue-edit-title" type="text" maxlength="255"' in html
    assert 'id="issue-edit-body" maxlength="10000"' in html
    assert 'id="save-issue-content"' in html
    assert 'id="cancel-issue-content"' in html
    assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
    assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
    assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
    assert 'buildMyWork.replaceIssueContent' in html


@pytest.mark.anyio
async def test_mobile_filed_issue_exposes_only_open_author_revision_controls():
    html = await dashboard()

    assert 'class="issue-content-editor"' in html
    assert "const revisableFiling = item.is_filed && !item.is_completed && detail.state === 'open';" in html
    assert "qs('#edit-issue-content').textContent = revisableFiling ? 'Revise filing' : 'Edit issue';" in html
    assert "qs('#edit-issue-content').disabled = readOnly && !revisableFiling;" in html
    assert '#issue-sheet.read-only .issue-content-editor' not in html
    assert '.issue-content-editor { max-width:100%; overflow-x:hidden;' in html
    assert '.issue-edit-form textarea { min-height:132px;' in html
    assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
    assert "history.pushState({ ...history.state, stackchainIssueEdit:true }, '', window.location.href);" in html
    assert "window.addEventListener('popstate', closeIssueEditorFromHistory);" in html
    assert "if (!qs('#issue-edit-form').hidden)" in html


@pytest.mark.anyio
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
    html = await dashboard()

    assert 'id="find-work"' in html
    assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
    assert 'id="find-work-list"' in html
    assert 'id="find-work-status" class="small" aria-live="assertive"' in html
    assert 'id="load-more-available"' in html
    assert 'static/pick-work.js' in html
    assert '.find-work-action { min-height:44px;' in html
    assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
    assert '@media(max-width:320px)' in html
    assert 'const retainedItems = findWorkController.items();' in html
    assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
    assert 'Refreshing available issues…' in html


@pytest.mark.anyio
async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requests():
    html = await dashboard()

    assert 'data-preview-index=' in html
    assert 'aria-expanded="' in html
    assert 'aria-controls="' in html
    assert "const detailId = 'find-work-detail-' + index" in html
    assert 'class="find-work-detail"' in html
    assert "renderMarkdown(item.body || 'No description provided.')" in html
    assert '>Open in Gitea</a>' in html
    assert '.find-work-card a, .find-work-more { min-height:44px;' in html
    assert '.find-work-detail { min-width:0;' in html


def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let calls = [];
let release;
const capture = createIssueCapture({{
  storage,
  fetchJson: (url, options) => {{
    calls.push({{url, options}});
    return new Promise(resolve => {{ release = () => resolve({{
      id:81, number:17, title:'Capture work', state:'open', repository:'stackchain/api',
      labels:[], assignees:['timmy'], url:'https://forge.example/issues/17'
    }}); }});
  }},
}});
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
capture.saveDraft(draft);
const first = capture.submit(draft);
const duplicate = capture.submit(draft);
const during = capture.loadDraft();
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls:calls.map(call => ({{url:call.url, method:call.options.method,
    body:JSON.parse(call.options.body)}})), during, after:capture.loadDraft(), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues",
        "method": "POST",
        "body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
    }]
    assert output["during"] == {
        "repository": "stackchain/api", "title": "Capture work", "body": "Context",
        "labelIds": [3],
    }
    assert output["after"] == {
        "repository": "", "title": "", "body": "", "labelIds": []
    }
    assert output["results"][0]["number"] == 17
    assert output["results"][1]["number"] == 17


def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const capture = createIssueCapture({{
  storage,
  fetchJson: url => {{
    calls += 1;
    if (calls === 1) return Promise.reject(new Error('temporary'));
    return new Promise(resolve => {{ release = () => resolve({{
      items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
    }}); }});
  }},
}});
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
capture.loadRepositoryPage(2).catch(() => {{
  const first = capture.loadRepositoryPage(2);
  const duplicate = capture.loadRepositoryPage(2);
  release();
  Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
    calls, results, draft:capture.loadDraft()
  }})));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 2
    assert output["results"] == [
        {"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
        {"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
    ]
    assert output["draft"] == {
        "repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
    }


def test_issue_capture_repository_search_ignores_late_results_and_preserves_draft():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const pending = {{}};
const calls = [];
const capture = createIssueCapture({{
  storage,
  fetchJson: url => {{
    calls.push(url);
    return new Promise(resolve => {{ pending[url] = resolve; }});
  }},
}});
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
const first = capture.searchRepositories('mob');
const second = capture.searchRepositories('mobile');
pending['api/v1/repositories/search?q=mobile&limit=20']({{items:[{{full_name:'stackchain/mobile'}}]}});
second.then(secondResult => {{
  pending['api/v1/repositories/search?q=mob&limit=20']({{items:[{{full_name:'stackchain/obsolete'}}]}});
  first.then(firstResult => process.stdout.write(JSON.stringify({{
    calls, firstResult, secondResult, draft:capture.loadDraft()
  }})));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output == {
        "calls": [
            "api/v1/repositories/search?q=mob&limit=20",
            "api/v1/repositories/search?q=mobile&limit=20",
        ],
        "firstResult": {"status": "stale", "items": []},
        "secondResult": {"status": "ready", "items": [{"full_name": "stackchain/mobile"}]},
        "draft": {
            "repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3],
        },
    }


def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const calls = [];
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
const first = createIssueCapture({{
  storage,
  createOperationId: () => 'operation-177',
  fetchJson: (_url, options) => {{ calls.push(options.headers['Idempotency-Key']); return Promise.reject(new Error('timeout')); }},
}});
first.submit(draft).catch(() => {{
  const restored = createIssueCapture({{
    storage,
    createOperationId: () => 'must-not-replace-operation-177',
    fetchJson: (_url, options) => {{
      calls.push(options.headers['Idempotency-Key']);
      return Promise.resolve({{number:17, title:'Capture work'}});
    }},
  }});
  const before = restored.loadDraft();
  restored.submit(before).then(issue => process.stdout.write(JSON.stringify({{
    calls, before, after:restored.loadDraft(), number:issue.number
  }})));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == ["operation-177", "operation-177"]
    assert output["before"] == {
        "repository": "stackchain/api", "title": "Capture work", "body": "Context",
        "labelIds": [3],
    }
    assert output["after"] == {
        "repository": "", "title": "", "body": "", "labelIds": []
    }
    assert output["number"] == 17


def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const normalized = createIssueCapture.normalizeSharedContent({{
  title: '  Production alert  ',
  text: 'Latency crossed the threshold https://status.example/incidents/42',
  url: 'https://status.example/incidents/42',
}});
process.stdout.write(JSON.stringify(normalized));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "title": "Production alert",
        "body": "Latency crossed the threshold https://status.example/incidents/42",
    }


def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
  text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "title": "Investigate checkout latency before the release window opens.",
        "body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
    }


def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{
  repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
}});
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
const before = capture.loadDraft();
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
const pendingAfterReload = capture.pendingSharedContent();
const accepted = capture.acceptSharedContent();
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "staged": {"status": "conflict"},
        "before": {
            "repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
            "labelIds": [3], "milestoneId": 9,
        },
        "pendingAfterReload": {
            "title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
        },
        "accepted": {
            "repository": "stackchain/api", "title": "Shared alert",
            "body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
        },
        "pending": None,
    }


def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
capture.discardSharedContent();
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
        "pending": None,
    }


def test_issue_capture_loads_repository_labels_with_priorities_first():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
  storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
  fetchJson: url => {{
    calls.push(url);
    return Promise.resolve([
      {{id:8,name:'frontend',color:'1d76db'}},
      {{id:3,name:'P0',color:'d73a4a'}},
      {{id:5,name:'critical',color:'b60205'}}
    ]);
  }},
}});
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
    assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]


def test_issue_capture_persists_and_submits_milestone_and_due_date():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const calls = [];
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const capture = createIssueCapture({{
  storage,
  createOperationId: () => 'planned-operation',
  fetchJson: (url, options) => {{
    calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
    if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
    return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
  }},
}});
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
capture.saveDraft(draft);
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
  process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [
        {"url": "api/v1/repos/stackchain/api/milestones", "body": None},
        {
            "url": "api/v1/repos/stackchain/api/issues",
            "body": {
                "title": "Ship plan", "body": "", "label_ids": [],
                "milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
            },
        },
    ]
    assert output["milestones"] == [{"id": 9, "title": "August RC"}]
    assert output["stored"]["number"] == 221


def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
  storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
  fetchJson: url => {{
    calls.push(url);
    return Promise.resolve({{items:[
      {{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
      {{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
      {{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
      {{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
      {{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
      {{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
      {{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
    ]}});
  }},
}});
capture.findDuplicates({{repository:'stackchain/api', title:'  Investigate checkout latency  '}}).then(state =>
  process.stdout.write(JSON.stringify({{calls, state}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [
        "api/v1/search?q=Investigate%20checkout%20latency&limit=10"
    ]
    assert output["state"]["status"] == "ready"
    assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]


def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const pending = [];
const capture = createIssueCapture({{
  storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
  fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
}});
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
const first = capture.findDuplicates(firstDraft);
const latest = capture.findDuplicates(latestDraft);
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
latest.then(latestState => {{
  const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
  capture.acknowledgeDuplicates(latestDraft);
  const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
  const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
  const changedSearch = capture.findDuplicates(changedDraft);
  pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
  changedSearch.then(() => {{
    const changed = capture.needsDuplicateAcknowledgement(changedDraft);
    pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
    first.then(staleState => {{
      const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
      pending[3].reject(new Error('offline'));
      failure.then(failedState => process.stdout.write(JSON.stringify({{
        latestState, staleState, blocked, allowed, changed, failedState,
        failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
      }})));
    }});
  }});
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["latestState"]["status"] == "ready"
    assert output["staleState"]["status"] == "stale"
    assert output["blocked"] is True
    assert output["allowed"] is False
    assert output["changed"] is True
    assert output["failedState"]["status"] == "failed"
    assert output["failureBlocks"] is False


def test_issue_capture_keeps_partial_duplicate_search_advisory():
    script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
const capture = createIssueCapture({{
  storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
  fetchJson: () => Promise.resolve({{partial:true, items:[
    {{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
  ]}}),
}});
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
  state, blocked:capture.needsDuplicateAcknowledgement(draft),
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["state"]["partial"] is True
    assert [item["number"] for item in output["state"]["candidates"]] == [24]
    assert output["blocked"] is False


def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
    payload = {
        "user": {"login": "timmy"},
        "issues": [{
            "id": 1, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
            "labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z",
            "url": "https://forge.example/stackchain/api/issues/7",
        }],
        "pull_requests": [],
        "notifications": [
            {
                "id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
                "subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T12:00:00Z",
                "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
            },
            {
                "id": 43, "number": 8, "title": "Mention only", "repository": "stackchain/web",
                "subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T13:00:00Z",
                "url": "https://forge.example/stackchain/web/issues/8#issuecomment-2",
            },
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify({{
  queue,
  updates: buildMyWork.filterMyWork(queue, 'update'),
  counts: buildMyWork.countMyWork(queue),
  summary: buildMyWork.summarizeMyWork(queue),
}}));
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert len(output["queue"]) == 2
    assert [item["key"] for item in output["updates"]] == ["stackchain/api#7", "stackchain/web#8"]
    assert output["updates"][0]["kind"] == "issue"
    assert output["updates"][0]["update_reason"] == "Assigned to you"
    assert output["updates"][0]["url"].endswith("#issuecomment-9")
    assert output["updates"][1]["kind"] == "update"
    assert output["updates"][1]["update_reason"] == ""
    assert output["counts"] == {
        "all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
    }
    assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"


def test_unread_update_correlation_distinguishes_issue_and_pull_with_same_number():
    payload = {
        "user": {"login": "timmy"},
        "issues": [{"number": 7, "title": "Issue seven", "repository": "stackchain/api", "url": "https://forge.example/issues/7"}],
        "pull_requests": [{"number": 7, "title": "Pull seven", "repository": "stackchain/api", "url": "https://forge.example/pulls/7"}],
        "notifications": [{
            "id": 42, "number": 7, "title": "Issue seven", "repository": "stackchain/api",
            "subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment-1",
        }],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    queue = json.loads(result.stdout)

    issue = next(item for item in queue if item["kind"] == "issue")
    pull = next(item for item in queue if item["kind"] == "pull")
    assert issue["has_update"] is True
    assert issue["url"].endswith("#comment-1")
    assert pull["has_update"] is False


def test_notification_identity_survives_merge_and_acknowledgement_preserves_assigned_work():
    payload = {
        "user": {"login": "timmy"},
        "issues": [{
            "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
            "assignees": ["timmy"], "url": "https://forge.example/issues/7",
        }],
        "notifications": [
            {"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
             "subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment"},
            {"id": 43, "number": 8, "title": "Mention", "repository": "stackchain/web",
             "subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/8"},
        ],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const before = buildMyWork({json.dumps(payload)});
const afterMerged = buildMyWork.acknowledgeNotification(before, 42);
const afterStandalone = buildMyWork.acknowledgeNotification(before, 43);
process.stdout.write(JSON.stringify({{before, afterMerged, afterStandalone}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assigned = next(item for item in output["before"] if item["kind"] == "issue")
    assert assigned["notification_id"] == 42
    assert assigned["has_update"] is True
    assert len(output["afterMerged"]) == 2
    acknowledged = next(item for item in output["afterMerged"] if item["kind"] == "issue")
    assert acknowledged["has_update"] is False
    assert "notification_id" not in acknowledged
    assert [item["notification_id"] for item in output["afterStandalone"] if item["has_update"]] == [42]


def test_notification_acknowledger_is_single_flight_and_rolls_back_on_failure():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [{{kind:'update', notification_id:42, has_update:true}}];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createNotificationAcknowledger({{
  markRead: () => {{ calls += 1; return new Promise((resolve, reject) => {{ release = reject; }}); }},
  onItems: items => states.push(items),
  onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, 42);
const duplicate = controller.acknowledge(original, 42);
release(new Error('offline'));
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, states, statuses, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert output["states"] == [[], [{"kind": "update", "notification_id": 42, "has_update": True}]]
    assert output["statuses"] == ["Marking update read…", "Could not mark update read. Retry."]
    assert output["results"] == [False, False]


def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
  {{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
  {{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
  {{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createBulkNotificationAcknowledger({{
  markRead: ids => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
  }},
  onItems: items => states.push(items),
  onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, [42, 43, 42, 44]);
const duplicate = controller.acknowledge(original, [42, 43, 44]);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, states, statuses, results,
  retryIds: buildMyWork.notificationIds(states.at(-1)),
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert output["results"] == [
        {"marked": [42, 43], "failed": [44]},
        False,
    ]
    assert output["states"] == [[
        {"kind": "issue", "key": "repo#1", "has_update": False},
        {"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
    ]]
    assert output["retryIds"] == [44]
    assert output["statuses"] == [
        "Marking 3 updates read…",
        "2 marked read · 1 could not be updated — retry.",
    ]


def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const selection = buildMyWork.createNotificationSelection({{
  limit: 2,
  onChange: state => states.push(state),
}});
selection.start();
const first = selection.select(42);
const duplicate = selection.select(42);
selection.toggle(42);
selection.select(43);
selection.select(44);
const second = selection.select(45);
selection.cancel();
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["first"] == "selected"
    assert output["duplicate"] == "already-selected"
    assert output["second"] == "limit"
    assert output["states"] == [
        {"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
        {"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
        {"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
        {"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
        {"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
        {"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
    ]
    assert output["snapshot"] == output["states"][-1]


def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const selection = buildMyWork.createNotificationSelection({{
  onChange: state => states.push(state),
}});
selection.start();
selection.select(42);
selection.select(43);
selection.select(44);
const retained = selection.retain([44, 44, 99]);
process.stdout.write(JSON.stringify({{retained, states}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["retained"] == {
        "active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
    }
    assert output["states"][-1] == output["retained"]


def test_notification_pager_is_single_flight_and_merges_unique_updates():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const notifications = [];
const statuses = [];
const pager = buildMyWork.createNotificationPager({{
  load: page => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{
      items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
      page, total: 75, has_more: false,
    }}); }});
  }},
  onNotifications: items => notifications.push(items),
  onPagination: page => pages.push(page),
  onStatus: status => statuses.push(status),
}});
pager.reset({{page:1, total:75, has_more:true}});
const existing = [{{id:50, title:'newer'}}];
const first = pager.loadMore(existing);
const duplicate = pager.loadMore(existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, pages, notifications, statuses, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert output["notifications"] == [[
        {"id": 50, "title": "newer"},
        {"id": 51, "title": "older"},
    ]]
    assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
    assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
    assert output["results"] == [True, False]


def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const statuses = [];
const requested = [];
const pager = buildMyWork.createNotificationPager({{
  load: async page => {{ requested.push(page); throw new Error('offline'); }},
  onNotifications: items => states.push(items),
  onPagination: () => {{}},
  onStatus: status => statuses.push(status),
}});
pager.reset({{page:2, total:125, has_more:true}});
pager.loadMore([{{id:1}}]).then(result =>
  pager.loadMore([{{id:1}}]).then(retry =>
    process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
  )
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "requested": [3, 3],
        "states": [],
        "statuses": [
            "Loading older updates…", "Could not load older updates. Retry.",
            "Loading older updates…", "Could not load older updates. Retry.",
        ],
        "result": False,
        "retry": False,
    }


def test_notification_reader_marks_current_read_and_opens_next_update():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
  {{kind:'update', key:'repo#1', notification_id:42, has_update:true, title:'First'}},
  {{kind:'issue', key:'repo#2', notification_id:43, has_update:true, title:'Second'}},
];
const loaded = [];
const opened = [];
const details = [];
const states = [];
const statuses = [];
const closed = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => {{ loaded.push(id); return {{id, latest_comment:{{body:'Comment ' + id}}}}; }},
  markRead: async id => id,
  onOpen: item => opened.push(item.notification_id),
  onDetail: detail => details.push(detail.id),
  onItems: items => states.push(items),
  onStatus: status => statuses.push(status),
  onClose: () => closed.push(true),
}});
reader.open(original[0], original).then(() =>
  reader.markReadAndNext(original).then(result =>
    process.stdout.write(JSON.stringify({{loaded, opened, details, states, statuses, closed, result}}))
  )
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["loaded"] == [42, 43]
    assert output["opened"] == [42, 43]
    assert output["details"] == [42, 43]
    assert output["states"] == [[
        {"kind": "issue", "key": "repo#2", "notification_id": 43,
         "has_update": True, "title": "Second"},
    ]]
    assert output["statuses"] == [
        "Loading update…", "Update ready.", "Marking update read…",
        "Loading update…", "Update ready.",
    ]
    assert output["closed"] == []
    assert output["result"]["next"]["notification_id"] == 43


def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
const loaded = [], details = [], statuses = [];
const releases = {{}};
const reader = buildMyWork.createNotificationReader({{
  load: id => {{
    loaded.push(id);
    if (id === 1) return Promise.resolve({{id}});
    return new Promise(resolve => {{ releases[id] = resolve; }});
  }},
  markRead: async () => {{}},
  onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
  onStatus: status => statuses.push(status), onClose: () => {{}},
}});
(async () => {{
  await reader.open(items[0]);
  const first = reader.prefetch(items[1]);
  const duplicate = reader.prefetch(items[1]);
  const rejected = reader.prefetch(items[2]);
  const advancing = reader.open(items[1]);
  await Promise.resolve();
  releases[2]({{id:2}});
  await advancing;
  process.stdout.write(JSON.stringify({{
    loaded, details, same:first === duplicate, rejected, statuses,
  }}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output == {
        "loaded": [1, 2],
        "details": [1, 2],
        "same": True,
        "rejected": False,
        "statuses": ["Loading update…", "Update ready.", "Update ready."],
    }


def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let scope = 'timmy';
const loaded = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
  getScope: () => scope,
  markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
  onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
  onClose: () => {{}},
}});
(async () => {{
  await reader.open({{notification_id:1}});
  await reader.prefetch({{notification_id:2}});
  scope = 'alexander';
  const opened = await reader.open({{notification_id:2}});
  process.stdout.write(JSON.stringify({{loaded, opened}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}


def test_notification_reader_acknowledges_once_and_opens_next_update():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
  {{kind:'update', notification_id:42, has_update:true}},
  {{kind:'update', notification_id:43, has_update:true}},
];
let release;
const calls = [];
const events = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => ({{id}}), markRead: async () => {{}},
  acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
  onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
  onItems: next => events.push(['items', next.map(item => item.notification_id)]),
  onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
  await reader.open(items[0]);
  events.length = 0;
  const first = reader.acknowledgeAndNext(items);
  const duplicate = reader.acknowledgeAndNext(items);
  await Promise.resolve();
  release({{reaction:'created', status:'read'}});
  const results = await Promise.all([first, duplicate]);
  process.stdout.write(JSON.stringify({{calls, events, results}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == [42]
    assert output["events"] == [
        ["status", "Adding reaction and marking read…"],
        ["items", [43]],
        ["open", 43],
        ["status", "Loading update…"],
        ["status", "Update ready."],
    ]
    assert output["results"][0]["next"]["notification_id"] == 43
    assert output["results"][1] is False


def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
    script = f"""
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
const states = [];
const statuses = [];
const calls = [];
let attempts = 0;
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
const undo = createNotificationUndo({{
  restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
  onItems: items => states.push(items.map(value => value.notification_id)),
  onStatus: status => statuses.push(status),
}});
(async () => {{
  undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
  const first = undo.run();
  const duplicate = undo.run();
  const firstResults = await Promise.all([first, duplicate]);
  const retry = await undo.run();
  process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == [42, 42]
    assert output["states"] == [[42, 43]]
    assert output["firstResults"] == [False, False]
    assert output["retry"] is True
    assert output["statuses"] == [
        "repo#7 marked read. Undo?",
        "Restoring repo#7…",
        "Could not restore repo#7. Retry Undo.",
        "Restoring repo#7…",
        "repo#7 is unread again.",
    ]


@pytest.mark.anyio
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
    html = await dashboard()

    assert 'id="notification-undo"' in html
    assert 'id="undo-notification" type="button"' in html
    assert "requestNotificationUnread" in html
    assert "createDashboardNotificationUndo({" in html
    assert ".notification-undo { position:fixed;" in html
    assert "env(safe-area-inset-bottom)" in html
    assert ".notification-undo button { min-height:44px;" in html


@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
    html = await dashboard()

    assert (
        'id="acknowledge-update-next" type="button" hidden '
        'aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>'
    ) in html
    assert "qs('#acknowledge-update-next').hidden = true;" in html
    assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
    assert "async function acknowledgeNotification(notificationId)" in html
    assert "'/acknowledge', { method: 'POST'" in html
    assert "acknowledge: acknowledgeNotification" in html
    assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
    assert "qs('#acknowledge-update-next').disabled = offline;" in html
    assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html


@pytest.mark.anyio
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
    html = await dashboard()

    assert 'id="mute-update-next" type="button" hidden' in html
    assert ">Mute future updates &amp; next</button>" in html
    assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
    assert "async function muteNotification(notificationId)" in html
    assert "'/mute', { method: 'POST'" in html
    assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
    assert "Future updates are muted; current item is still unread" in html
    assert "qs('#mute-update-next').disabled = offline;" in html
    assert ".update-sheet-actions #mute-update-next" in html


def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
  load: async () => {{ throw new Error('offline'); }},
  markRead: async () => {{}}, onOpen: () => events.push('open'),
  onDetail: () => events.push('detail'), onItems: () => events.push('items'),
  onStatus: status => events.push(status), onClose: () => events.push('close'),
}});
reader.open(item, [item]).then(result =>
  process.stdout.write(JSON.stringify({{result, events}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "result": False,
        "events": [
            "open", "Loading update…",
            "Could not load update. Retry or open it in Gitea.",
        ],
    }


def test_notification_reader_does_not_advance_or_remove_item_when_mark_read_fails():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => ({{id}}), markRead: async () => {{ throw new Error('offline'); }},
  onOpen: () => events.push('open'), onDetail: () => events.push('detail'),
  onItems: () => events.push('items'), onStatus: status => events.push(status),
  onClose: () => events.push('close'),
}});
reader.open(item, [item]).then(() =>
  reader.markReadAndNext([item]).then(result =>
    process.stdout.write(JSON.stringify({{result, events}}))
  )
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "result": False,
        "events": [
            "open", "Loading update…", "detail", "Update ready.",
            "Marking update read…", "Could not mark update read. Retry.",
        ],
    }


def test_notification_reader_pages_conversation_single_flight_and_ignores_stale_update():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const details = {{
  42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}},
  43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}},
}};
let release;
const requested = [];
const states = [];
const reader = build.createNotificationReader({{
  load: async id => details[id], createPager,
  loadConversation: (id, page) => {{
    requested.push([id, page]);
    return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }});
  }},
  markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
  onConversation: state => states.push(state.comments.map(item => item.id)),
  onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
const firstItem = {{notification_id:42}};
const secondItem = {{notification_id:43}};
reader.open(firstItem).then(async () => {{
  const first = reader.loadOlder();
  const duplicate = reader.loadOlder();
  await reader.open(secondItem);
  release();
  await Promise.all([first, duplicate]);
  process.stdout.write(JSON.stringify({{requested, states}}));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["requested"] == [[42, 2]]
    assert output["states"] == [[41], [90]]


def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const events = [];
let attempts = 0;
let release;
const reader = build.createNotificationReader({{
  load: async id => ({{id, title:'Core ready', conversation_available:true}}),
  createPager,
  loadConversation: () => {{
    attempts += 1;
    if (attempts === 1) return Promise.reject(new Error('comments offline'));
    return new Promise(resolve => {{ release = () => resolve({{
      comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
    }}); }});
  }},
  markRead: async () => {{}}, onOpen: () => events.push('open'),
  onDetail: detail => events.push(['detail', detail.title]),
  onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
  onConversationStatus: status => events.push(['conversation-status', status]),
  onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
}});
(async () => {{
  const opened = await reader.open({{notification_id:42}});
  const retrying = reader.retryConversation();
  await Promise.resolve();
  const beforeRelease = events.slice();
  release();
  const retried = await retrying;
  process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["opened"] is True
    assert output["retried"] is True
    assert output["attempts"] == 2
    assert ["detail", "Core ready"] in output["beforeRelease"]
    assert ["status", "Update ready."] in output["beforeRelease"]
    assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
    assert ["conversation", [47]] not in output["beforeRelease"]
    assert output["events"][-2:] == [
        ["conversation", [47]],
        ["conversation-status", "1 of 47 messages loaded."],
    ]


def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const saved = {{
  id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
  conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
}};
let detailLoads = 0;
let conversationLoads = 0;
let markReads = 0;
const details = [];
const conversations = [];
const reader = build.createNotificationReader({{
  load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
  loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
  markRead: async () => {{ markReads += 1; }}, createPager,
  onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
  onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
  onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
(async () => {{
  const opened = await reader.open(item, saved);
  const older = await reader.loadOlder();
  const marked = await reader.markReadAndNext([item]);
  process.stdout.write(JSON.stringify({{
    opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
  }}));
}})();
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "opened": True,
        "older": False,
        "marked": False,
        "detailLoads": 0,
        "conversationLoads": 0,
        "markReads": 0,
        "details": ["Saved update"],
        "conversations": [[41]],
    }


def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const items = [
  {{kind:'update', notification_id:42, has_update:true, title:'First'}},
  {{kind:'update', notification_id:43, has_update:true, title:'Second'}},
];
const saved = new Map([
  [42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
  [43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
]);
const events = [];
const reader = build.createNotificationReader({{
  load: async () => {{ throw new Error('network must not run'); }},
  markRead: async () => {{ throw new Error('network must not run'); }},
  queueRead: async id => events.push(['queued', id]),
  loadSaved: item => saved.get(item.notification_id),
  onOpen: item => events.push(['open', item.notification_id]),
  onDetail: detail => events.push(['detail', detail.id]),
  onItems: next => events.push(['items', next.map(item => item.notification_id)]),
  onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
  await reader.open(items[0], saved.get(42));
  events.length = 0;
  const result = await reader.markReadAndNext(items);
  process.stdout.write(JSON.stringify({{result, events}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["result"]["next"]["notification_id"] == 43
    assert output["events"] == [
        ["status", "Queueing update read…"],
        ["queued", 42],
        ["items", [43]],
        ["open", 43],
        ["status", "Loading update…"],
        ["detail", 43],
        ["status", "Update ready."],
    ]


def test_notification_reader_appends_a_confirmed_reply_exactly_once():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const states = [];
const reader = build.createNotificationReader({{
  load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}),
  loadConversation: async () => ({{}}), createPager,
  markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
  onConversation: state => states.push(state.comments.map(item => item.id)),
  onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
reader.open({{notification_id:42}}).then(() => {{
  reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
  reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
  process.stdout.write(JSON.stringify(states));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]]


def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
  {{kind:'update', notification_id:42, has_update:true}},
  {{kind:'update', notification_id:43, has_update:true}},
];
const opened = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => ({{id}}), markRead: async () => {{}},
  onOpen: item => opened.push(item.notification_id), onDetail: () => {{}},
  onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
reader.open(items[1], items).then(() => reader.markReadAndNext(items)).then(result =>
  process.stdout.write(JSON.stringify({{opened, next:result.next.notification_id}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {"opened": [43, 42], "next": 42}


def test_notification_reader_closes_and_announces_when_final_update_is_cleared():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
  load: async id => ({{id}}), markRead: async () => {{}},
  onOpen: () => {{}}, onDetail: () => {{}}, onItems: items => events.push(['items', items]),
  onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
reader.open(item, [item]).then(() => {{
  events.length = 0;
  return reader.markReadAndNext([item]);
}}).then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["result"] == {"items": [], "item": {
        "kind": "update", "notification_id": 42, "has_update": True,
    }, "next": None}
    assert output["events"] == [
        ["status", "Marking update read…"],
        ["items", []],
        ["close"],
        ["status", "Inbox cleared."],
    ]


def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
    script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
let calls = 0;
const pager = createConversationPager({{
  loadPage: async page => {{ calls += 1; return {{
    comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
    page, older_page:null, total:3
  }}; }}
}});
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
const first = pager.loadOlder();
const duplicate = pager.loadOlder();
Promise.all([first, duplicate]).then(() => {{
  pager.append({{id:22,body:'posted'}});
  pager.append({{id:22,body:'posted'}});
  process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
}});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == 1
    assert output["same"] is True
    assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
    assert output["state"]["older_page"] is None
    assert output["state"]["total"] == 4


def test_owned_comment_actions_edit_delete_all_conversation_routes_and_preserve_failed_edit():
    script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const createCommentActions = require({json.dumps(str(COMMENT_ACTIONS))});
const requests = [];
let shouldFail = false;
const controller = createCommentActions({{
  getLogin: () => 'timmy',
  confirmDelete: () => true,
  fetchJson: async (url, options) => {{
    requests.push([url, options.method, options.body || null]);
    if (shouldFail) throw new Error('offline');
    if (options.method === 'DELETE') return {{id:42,deleted:true}};
    return {{id:42,author:'timmy',body:JSON.parse(options.body).body,created_at:'2026-08-11T10:00:00Z'}};
  }},
}});
const cancelController = createCommentActions({{
  getLogin: () => 'timmy', confirmDelete: () => false,
  fetchJson: async () => {{ throw new Error('delete should not run'); }},
}});
const contexts = [
  {{kind:'issue',item:{{repository:'stackchain/api',number:17}}}},
  {{kind:'pull',item:{{repository:'stackchain/api',number:17}}}},
  {{kind:'update',item:{{notification_id:91}}}},
];
(async () => {{
  const results = [];
  for (const context of contexts) {{
    const pager = createConversationPager({{loadPage:async()=>({{}})}});
    pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}},{{id:43,author:'alex',body:'Other'}}],total:2}});
    results.push(controller.isOwned(pager.snapshot().comments[0]));
    results.push(controller.isOwned(pager.snapshot().comments[1]));
    await controller.edit(context, pager, 42, 'Corrected');
    results.push(pager.snapshot().comments[0].body);
    await controller.remove(context, pager, 42);
    results.push([pager.snapshot().comments.map(item=>item.id), pager.snapshot().total]);
  }}
  const pager = createConversationPager({{loadPage:async()=>({{}})}});
  pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}}],total:1}});
  shouldFail = true;
  let failure = '';
  try {{ await controller.edit(contexts[0], pager, 42, 'Unsaved correction'); }} catch (error) {{ failure=error.message; }}
  const cancelled = await cancelController.remove(contexts[0], pager, 42);
  process.stdout.write(JSON.stringify({{requests,results,failure,cancelled,state:pager.snapshot()}}));
}})();
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)
    assert [request[:2] for request in output["requests"][:6]] == [
        ["api/v1/repos/stackchain/api/issues/17/comments/42", "PATCH"],
        ["api/v1/repos/stackchain/api/issues/17/comments/42", "DELETE"],
        ["api/v1/repos/stackchain/api/pulls/17/comments/42", "PATCH"],
        ["api/v1/repos/stackchain/api/pulls/17/comments/42", "DELETE"],
        ["api/v1/notifications/91/comments/42", "PATCH"],
        ["api/v1/notifications/91/comments/42", "DELETE"],
    ]
    assert output["results"] == [True, False, "Corrected", [[43], 1]] * 3
    assert output["failure"] == "offline"
    assert output["cancelled"] is None
    assert output["state"]["comments"][0]["body"] == "Original"


@pytest.mark.anyio
async def test_owned_comment_actions_render_inline_for_mobile_in_all_conversations():
    html = await dashboard()
    dashboard_javascript = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
    action_javascript = COMMENT_ACTIONS.read_text()
    javascript = dashboard_javascript + action_javascript
    css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
    service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()

    assert '<script src="static/comment-actions.js"></script>' in html
    assert html.index('static/comment-actions.js') < html.index('static/dashboard.js')
    assert "BASE + 'static/comment-actions.js'" in service_worker
    assert 'data-comment-action="edit"' in javascript
    assert 'data-comment-action="delete"' in javascript
    assert 'class="comment-edit-textarea"' in javascript
    assert "wireCommentActions('#issue-comments')" in javascript
    assert "wireCommentActions('#pull-comments')" in javascript
    assert "wireCommentActions('#update-comments')" in javascript
    assert ".comment-owned-actions button" in css and "min-height:44px" in css
    assert ".comment-edit-textarea" in css and "max-width:100%" in css
    assert ".issue-comment" in css and "overflow-wrap:anywhere" in css


def test_notification_reader_exposes_current_comment_pager_only_while_open():
    script = f"""
const {{createNotificationReader}} = require({json.dumps(str(MY_WORK))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const reader = createNotificationReader({{
  load: async () => ({{conversation:{{comments:[{{id:42,body:'Before'}}],total:1}}}}),
  markRead: async()=>{{}}, onOpen:()=>{{}}, onDetail:()=>{{}}, onItems:()=>{{}}, onStatus:()=>{{}}, onClose:()=>{{}},
  loadConversation: async()=>({{}}), createPager:createConversationPager,
}});
(async()=>{{
  const before = reader.commentPager();
  await reader.open({{notification_id:91}});
  const pager = reader.commentPager();
  pager.replace({{id:42,body:'After'}});
  process.stdout.write(JSON.stringify({{before:before===null,body:pager.snapshot().comments[0].body}}));
}})();
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {"before": True, "body": "After"}


def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
    script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
  error:error.message, state:pager.snapshot()
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["error"] == "offline"
    assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
    assert output["state"]["older_page"] == 1


def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createIssueSheet({{
  createConversationPager,
  fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
  {{repository:'stackchain/api',number:7}},
  {{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
    assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]


def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createPullSheet({{
  createConversationPager,
  fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
  {{repository:'stackchain/api',number:7}},
  {{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
    assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]


def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let request;
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
  request = {{url, accept:options.headers.Accept}};
  return {{title:'Fix mobile flow'}};
}} }});
controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
  process.stdout.write(JSON.stringify({{request, title:detail.title}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "request": {
            "url": "api/v1/repos/stackchain/api/issues/7/detail",
            "accept": "application/json",
        },
        "title": "Fix mobile flow",
    }


def test_issue_sheet_loads_labels_through_assigned_issue_boundary():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let request;
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
  request = {{url, accept:options.headers.Accept}};
  return [{{id:3, name:'P0'}}];
}} }});
controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels =>
  process.stdout.write(JSON.stringify({{request, labels}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    assert json.loads(result.stdout) == {
        "request": {
            "url": "api/v1/repos/stackchain/api/issues/7/labels",
            "accept": "application/json",
        },
        "labels": [{"id": 3, "name": "P0"}],
    }


def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const controller = createIssueSheet({{
  storage,
  fetchJson: (url, options) => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{id:82, body:'Ready'}}); }});
  }},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ready');
const first = controller.comment(item, 'Ready');
const duplicate = controller.comment(item, 'Ready');
const during = controller.loadDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, during, after:controller.loadDraft(item), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == 1
    assert output["during"] == "Ready"
    assert output["after"] == ""
    assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]


def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_until_confirmed():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
  getItem:key => values.get(key) || null,
  setItem:(key,value) => values.set(key,value),
  removeItem:key => values.delete(key),
}};
const calls = [];
let release;
const controller = createIssueSheet({{
  storage,
  fetchJson: (url, options={{}}) => {{
    calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
    return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }});
  }},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.updateLabels(item, [3]);
const duplicate = controller.updateLabels(item, [3]);
const during = controller.loadLabelDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
  calls, during, after:controller.loadLabelDraft(item), results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/issues/7/labels",
        "method": "PATCH",
        "body": {"label_ids": [3]},
    }]
    assert output["during"] == [3]
    assert output["after"] == []
    assert output["results"] == [
        {"number": 7, "labels": ["P0"]},
        {"number": 7, "labels": ["P0"]},
    ]


def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let release;
const controller = createIssueSheet({{
  fetchJson: (url, options) => {{
    calls += 1;
    return new Promise(resolve => {{ release = () => resolve({{number:7, state:'closed'}}); }});
  }},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.close(item);
const duplicate = controller.close(item);
release();
Promise.all([first, duplicate]).then(results =>
  process.stdout.write(JSON.stringify({{calls, results}}))
);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "calls": 1,
        "results": [
            {"number": 7, "state": "closed"},
            {"number": 7, "state": "closed"},
        ],
    }


def test_pull_sheet_preserves_comment_draft_and_single_flights_mutations():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
let releaseComment;
const controller = createPullSheet({{
  storage,
  fetchJson: (url, options={{}}) => {{
    calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
    if (url.endsWith('/comments')) return new Promise(resolve => {{ releaseComment = () => resolve({{id:91, body:'Ship it'}}); }});
    return Promise.resolve({{number:7, merged:true, state:'closed'}});
  }},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ship it');
const first = controller.comment(item, 'Ship it');
const duplicate = controller.comment(item, 'Ship it');
const during = controller.loadDraft(item);
releaseComment();
Promise.all([first, duplicate]).then(async comments => {{
  const merges = await Promise.all([controller.merge(item, 'abc123'), controller.merge(item, 'abc123')]);
  process.stdout.write(JSON.stringify({{calls, during, after:controller.loadDraft(item), comments, merges}}));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert output["during"] == "Ship it"
    assert output["after"] == ""
    assert output["calls"] == [
        {"url": "api/v1/repos/stackchain/api/pulls/7/comments", "method": "POST", "body": {"body": "Ship it"}},
        {"url": "api/v1/repos/stackchain/api/pulls/7/merge", "method": "POST", "body": {"expected_head_sha": "abc123"}},
    ]
    assert len(output["comments"]) == 2 and len(output["merges"]) == 2


def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const data = {{
  issues:[{{repository:'stackchain/api',number:7}}],
  pulls:[
    {{repository:'stackchain/api',number:7,title:'Transfer me'}},
    {{repository:'stackchain/web',number:7,title:'Keep me'}}
  ]
}};
const item = {{repository:'stackchain/api',number:7}};
process.stdout.write(JSON.stringify({{
  snapshot:createPullSheet.removeFromSnapshot(data, item),
  same:createPullSheet.sameTarget(item, {{repository:'stackchain/api',number:7}}),
  selectors:createPullSheet.ownershipSelectors()
}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)
    assert output["snapshot"]["issues"] == [{"repository": "stackchain/api", "number": 7}]
    assert output["snapshot"]["pulls"] == [{"repository": "stackchain/web", "number": 7, "title": "Keep me"}]
    assert output["same"] is True
    assert output["selectors"] == [
        "#release-pull", "#load-pull-handoff", "#pull-handoff-recipient", "#confirm-pull-handoff"
    ]


def test_pull_sheet_ownership_presenters_render_candidates_and_exit_messages():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const children = [];
const elements = new Map([
  ['#pull-ownership', {{open:true}}],
  ['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
  ['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
  ['#load-pull-handoff', {{disabled:true}}],
  ['#release-pull', {{disabled:true,textContent:''}}],
  ['#pull-handoff-status', {{textContent:''}}],
]);
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
const doc = {{
  createElement:() => ({{value:'',textContent:''}}),
  querySelector:selector => elements.get(selector)
}};
const count = createPullSheet.renderHandoffCandidates(select, [
  {{login:'alex',name:'Alexander'}}, {{login:'sam',name:'sam'}}
], doc);
createPullSheet.resetOwnershipControls(doc, {{key:'stackchain/api#7'}}, () => true);
const item = {{key:'stackchain/api#7'}};
process.stdout.write(JSON.stringify({{
  count, children,
  reset:Object.fromEntries(elements),
  handed:createPullSheet.ownershipExitMessage(item, 'handed off to @alex', 'opened'),
  released:createPullSheet.ownershipExitMessage(item, 'released', 'gated')
}}));
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)
    assert output["count"] == 2
    assert output["children"] == [
        {"value": "", "textContent": "Select a teammate"},
        {"value": "alex", "textContent": "Alexander (@alex)"},
        {"value": "sam", "textContent": "sam"},
    ]
    assert output["reset"]["#pull-ownership"]["open"] is False
    assert output["reset"]["#confirm-pull-handoff"]["textContent"] == "Hand off & next"
    assert output["reset"]["#release-pull"]["textContent"] == "Release & next"
    assert output["reset"]["#pull-handoff-status"]["textContent"] == "Load teammates to transfer ownership."
    assert output["handed"] == "stackchain/api#7 handed off to @alex. Next work item opened."
    assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."


def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let finish;
const controller = createPullSheet({{
  storage: null,
  fetchJson: (url, options={{}}) => {{
    calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
    if (url.endsWith('/handoff')) return new Promise(resolve => {{ finish = resolve; }});
    return Promise.resolve(url.endsWith('/handoff-candidates') ? [{{login:'alex',name:'Alexander'}}] : {{assignees:[]}});
  }}
}});
const item = {{repository:'stackchain/api',number:7}};
(async () => {{
  const candidates = await controller.loadHandoffCandidates(item);
  const first = controller.handoff(item, 'alex');
  const second = controller.handoff(item, 'alex');
  finish({{assignees:['alex'],recipient:'alex'}});
  const handoffs = await Promise.all([first, second]);
  const released = await controller.release(item);
  process.stdout.write(JSON.stringify({{calls,candidates,handoffs,released}}));
}})();
"""
    output = json.loads(subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    ).stdout)

    assert output["calls"] == [
        {"url": "api/v1/repos/stackchain/api/pulls/7/handoff-candidates", "method": "GET", "body": None},
        {"url": "api/v1/repos/stackchain/api/pulls/7/handoff", "method": "PATCH", "body": {"recipient": "alex"}},
        {"url": "api/v1/repos/stackchain/api/pulls/7/release", "method": "PATCH", "body": None},
    ]
    assert output["candidates"] == [{"login": "alex", "name": "Alexander"}]
    assert output["handoffs"] == [
        {"assignees": ["alex"], "recipient": "alex"},
        {"assignees": ["alex"], "recipient": "alex"},
    ]
    assert output["released"] == {"assignees": []}


def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const controller = createPullSheet({{
  storage: null,
  fetchJson: () => Promise.resolve({{
    number: 7,
    merged: false,
    state: 'unknown',
    confirmation_pending: true,
    error: 'Merge confirmation is pending. Check its status before retrying.',
  }}),
}});
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
  .then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
  .catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)

    assert json.loads(result.stdout) == {
        "resolved": False,
        "message": "Merge confirmation is pending. Check its status before retrying.",
    }


def test_pull_sheet_enables_merge_only_for_current_safe_state():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const states = [
  {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
  {{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
  {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc', checks:[
    {{name:'lint', state:'failure'}}, {{name:'release', state:'pending'}}, {{name:'build', state:'success'}}
  ]}},
  {{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
];
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)
    assert output[0] == {"allowed": True, "reason": "Ready to merge"}
    assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
    assert output[2] == {"allowed": False, "reason": "CI blocked by lint, release"}
    assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()


def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const item = {{repository:'stackchain/api', number:7}};
const detail = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc', files:[
  {{filename:'src/api.py'}}, {{filename:'frontend/app.js'}}
]}};
const first = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}});
const before = first.reviewState(item, detail);
const afterOne = first.toggleReviewed(item, detail, 'src/api.py');
const restored = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}}).reviewState(item, detail);
const complete = first.toggleReviewed(item, detail, 'frontend/app.js');
const changedHead = first.reviewState(item, {{...detail, head_sha:'def'}});
process.stdout.write(JSON.stringify({{
  before, afterOne, restored, complete, changedHead,
  blocked:createPullSheet.mergeEligibility(detail, afterOne),
  allowed:createPullSheet.mergeEligibility(detail, complete),
  next:first.nextUnreviewed(detail, afterOne),
}}));
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert output["before"] == {"reviewed": [], "total": 2, "complete": False}
    assert output["afterOne"]["reviewed"] == ["src/api.py"]
    assert output["restored"] == output["afterOne"]
    assert output["complete"]["complete"] is True
    assert output["changedHead"]["reviewed"] == []
    assert output["blocked"] == {"allowed": False, "reason": "Review every changed file before merging"}
    assert output["allowed"] == {"allowed": True, "reason": "Ready to merge"}
    assert output["next"] == "frontend/app.js"


def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let rejectFirst;
const fetchJson = url => {{
  calls.push(url);
  if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
  return Promise.resolve({{head_sha:'abc123', files:[]}});
}};
const sheet = createPullSheet({{fetchJson, storage:null}});
const item = {{repository:'stackchain/api', number:7}};
const first = sheet.loadReview(item, 'abc123');
const concurrent = sheet.loadReview(item, 'abc123');
rejectFirst(new Error('diff unavailable'));
Promise.allSettled([first, concurrent]).then(async failed => {{
  const retried = await sheet.loadReview(item, 'abc123');
  const cached = await sheet.loadReview(item, 'abc123');
  process.stdout.write(JSON.stringify({{
    same:first === concurrent,
    failed:failed.map(result => result.status),
    retried,
    cached,
    calls,
  }}));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert output["same"] is True
    assert output["failed"] == ["rejected", "rejected"]
    assert output["retried"]["head_sha"] == "abc123"
    assert output["cached"] == output["retried"]
    assert output["calls"] == [
        "api/v1/repos/stackchain/api/pulls/7/review-data",
        "api/v1/repos/stackchain/api/pulls/7/review-data",
    ]


def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v)}};
const calls = [];
const sheet = createPullSheet({{storage, fetchJson: url => {{
  calls.push(url);
  return Promise.resolve({{head_sha:'abc123', ci_state:calls.length === 1 ? 'pending' : 'success',
    checks:[{{name:'lint', state:calls.length === 1 ? 'pending' : 'success'}}],
    files:[{{filename:'src/api.py'}}]}});
}}}});
const item = {{repository:'stackchain/api', number:7}};
sheet.loadReview(item, 'abc123').then(first => {{
  sheet.toggleReviewed(item, first, 'src/api.py');
  return sheet.loadChecks(item).then(refreshed => {{
    const combined = {{...first, ...refreshed}};
    process.stdout.write(JSON.stringify({{calls, refreshed, progress:sheet.reviewState(item, combined)}}));
  }});
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert output["calls"] == [
        "api/v1/repos/stackchain/api/pulls/7/review-data",
        "api/v1/repos/stackchain/api/pulls/7/checks",
    ]
    assert output["refreshed"]["ci_state"] == "success"
    assert output["progress"] == {"reviewed": ["src/api.py"], "total": 1, "complete": True}


@pytest.mark.anyio
async def test_assigned_pull_sheet_exposes_mobile_ownership_exit_and_today_continuation():
    html = await dashboard()

    assert 'id="pull-ownership"' in html
    assert 'id="pull-handoff-recipient"' in html
    assert 'id="load-pull-handoff"' in html
    assert 'id="confirm-pull-handoff"' in html
    assert 'id="release-pull"' in html
    assert 'id="pull-handoff-status" class="small" aria-live="assertive"' in html
    assert '.pull-ownership select, .pull-ownership button { min-height:44px;' in html
    assert "createPullSheet.bindOwnershipControls(" in html
    assert "document, pullController, () => selectedPull" in html
    assert "createPullSheet.removeFromSnapshot(lastContextSnapshot" in html
    assert "if (continuing) return await completeOwnershipExitToday(item)" in html
    assert "createPullSheet.resetOwnershipControls(document, item" in html


@pytest.mark.anyio
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
    html = await dashboard()

    body = html.index('id="pull-sheet-body"')
    conversation = html.index('<h2>Full conversation</h2>', body)
    composer = html.index('id="pull-comment-title"', conversation)
    review = html.index('id="pull-review"', composer)
    files = html.index('id="pull-files"', review)

    assert '<summary><h2>Review &amp; merge</h2></summary>' in html
    assert '<details class="pull-review" id="pull-review">' in html
    assert 'id="pull-review-retry"' in html
    assert 'id="pull-review-status"' in html
    assert body < conversation < composer < review < files


def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const escapeHtml = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;');
const text = createPullSheet.renderFile({{
  filename:'src/<unsafe>.py', status:'modified', additions:1, deletions:1,
  diff_available:true, diff_lines:['@@ -1 +1 @@', '-old', '+new'], diff_truncated:true
}}, 0, false, escapeHtml);
const binary = createPullSheet.renderFile({{
  filename:'static/logo.png', diff_available:false, diff_binary:true, diff_truncated:false
}}, 1, true, escapeHtml);
process.stdout.write(JSON.stringify({{text, binary}}));
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert 'aria-controls="pull-diff-0"' in output["text"]
    assert "&lt;unsafe>" in output["text"] and "+new" in output["text"]
    assert "Preview truncated" in output["text"]
    assert 'data-pull-review-file="src/&lt;unsafe>.py"' in output["text"]
    assert "Binary file · preview unavailable" in output["binary"]
    assert 'aria-pressed="true"' in output["binary"] and "Reviewed" in output["binary"]


@pytest.mark.anyio
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
    html = await dashboard()

    assert 'id="pull-sheet"' in html and 'aria-modal="true"' in html
    assert 'class="my-work-card-main pull-trigger"' in html
    assert 'id="pull-sheet-status"' in html
    assert 'id="pull-files"' in html and 'id="pull-comments"' in html
    assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
    assert 'id="next-unreviewed-pull-file"' in html
    assert 'id="load-older-pull-comments"' in html
    assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
    assert '<script src="static/conversation.js"></script>' in html
    assert "pullController.conversation(item, detail.conversation)" in html
    assert "pullConversation.loadOlder()" in html
    assert "pullConversation.append(comment)" in html
    assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
    assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
    assert 'stackchain-feature-pull-workflow' in html
    assert "pullWorkflowFeatures.run('pull-workflow'" in html
    assert "let pullController = null" in html
    assert "let reviewController = null" in html
    assert "pullController.load(item)" in html
    assert "createPullSheet.renderFile" in html
    assert "pullController.toggleReviewed" in html
    assert "createPullSheet.focusNextUnreviewed(document" in html
    assert ".pull-diff { overflow-x:auto;" in html
    assert ".pull-file-toggle, .pull-review-file { min-height:44px;" in html
    assert "window.confirm('Merge ' + selectedPull.key" in html
    assert "expected_head_sha" in html
    assert "item.kind === 'pull'" in html and "pull-trigger" in html


@pytest.mark.anyio
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
    html = await dashboard()

    assert 'id="issue-sheet"' in html and 'aria-modal="true"' in html
    assert 'class="my-work-card-main issue-trigger"' in html
    assert 'id="close-issue-sheet"' in html
    assert 'id="retry-issue-load"' in html
    assert 'id="issue-sheet-body"' in html
    assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
    assert 'id="issue-comments"' in html
    assert 'id="load-older-issue-comments"' in html
    assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
    assert "issueController.conversation(item, detail.conversation)" in html
    assert "issueConversation.loadOlder()" in html
    assert "issueConversation.append(comment)" in html
    assert '.conversation-more { min-height:44px;' in html
    assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
    assert 'id="send-issue-comment"' in html
    assert 'id="close-issue"' in html
    assert 'id="open-issue-gitea"' in html and 'rel="noopener noreferrer"' in html
    assert '.issue-sheet-panel { width:min(560px,100%);' in html
    assert '.issue-sheet-content { overflow-wrap:anywhere;' in html
    assert '.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
    assert '<script src="static/issue-sheet.js"></script>' in html
    assert "issueController.load(item)" in html
    assert "issueController.comment(selectedIssue" in html
    assert "'Close ' + selectedIssue.key + '?'" in html
    assert "window.confirm(confirmation)" in html
    assert "issueController.close(selectedIssue)" in html
    assert "lastMyWork.filter" in html
    assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
    assert "e.key === 'Escape' && selectedIssue" in html


@pytest.mark.anyio
async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal():
    html = await dashboard()

    assert 'id="release-issue"' in html
    assert 'Release assignment' in html
    assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html
    assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html
    assert "buildMyWork.removeIssue(" in html


def test_remove_issue_updates_snapshot_without_mutating_other_work():
    payload = {
        "issues": [
            {"repository": "stackchain/api", "number": 17},
            {"repository": "stackchain/web", "number": 18},
        ],
        "pull_requests": [{"repository": "stackchain/api", "number": 17}],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17);
process.stdout.write(JSON.stringify({{updated, original}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)
    assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}]
    assert output["updated"]["pull_requests"] == payload["pull_requests"]
    assert output["original"]["issues"] == payload["issues"]


@pytest.mark.anyio
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
    html = await dashboard()

    assert 'id="issue-label-editor"' in html
    assert 'aria-describedby="issue-label-status"' in html
    assert 'id="issue-label-list"' in html
    assert 'id="save-issue-labels"' in html
    assert '.issue-label-option { min-height:44px;' in html
    assert 'max-width:100%;' in html
    assert "issueController.updateLabels(selectedIssue" in html
    assert "buildMyWork.replaceIssueLabels" in html
    assert "paintMyWork(lastContextSnapshot)" in html


@pytest.mark.anyio
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
    html = await dashboard()

    assert 'id="new-issue"' in html and 'New issue' in html
    assert 'id="create-issue-sheet"' in html and 'aria-modal="true"' in html
    assert 'id="create-issue-repository"' in html
    assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
    assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
    assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
    assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
    assert 'id="submit-new-issue"' in html
    assert '.create-issue-sheet.open { display:flex; }' in html
    assert 'height:100dvh;' in html
    assert 'env(safe-area-inset-bottom)' in html
    assert '<script src="static/create-issue-sheet.js"></script>' in html
    assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
    assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
    assert 'openRoutedWork(created' in html
    assert 'issueCapture.bindFilingMetadata({' in html
    assert "input[name=\"create-issue-label\"]:checked" in html
    assert '.create-issue-label-option' in html and 'min-height:44px' in html


def test_new_issue_capture_waits_for_retryable_feature_before_opening():
    dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()

    assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
    assert "if (!issueCapture)" in dashboard_source
    assert "async function openCreateIssueSheet" in dashboard_source


@pytest.mark.anyio
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
    html = await dashboard()

    assert 'id="create-issue-duplicates"' in html
    assert 'id="create-issue-duplicate-list"' in html
    assert 'aria-live="polite"' in html
    assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
    assert "issueCapture.findDuplicates(draft)" in html
    assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
    assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
    submit_handler = html.split("qs('#create-issue-form').addEventListener('submit'", 1)[1].split(
        "qs('#close-issue-sheet').addEventListener", 1
    )[0]
    assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in submit_handler
    assert "filingReview.open" in submit_handler
    assert "issueOutbox.enqueueDurably" not in submit_handler
    assert ".create-issue-duplicate-card" in html
    assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
    assert "overflow-wrap:anywhere" in html


@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
    html = await dashboard()

    assert html.index('id="my-work"') < html.index('data-panel-key="context"')
    assert 'data-work-filter="all"' in html
    assert 'data-work-filter="issue"' in html
    assert 'data-work-filter="pull"' in html
    assert 'data-work-filter="review"' in html
    assert 'data-work-filter="update"' in html
    assert '.work-filter' in html and 'min-height: 44px' in html
    assert '.my-work-card' in html and 'min-height: 44px' in html
    assert '<script src="static/my-work.js"></script>' in html
    assert "buildMyWork(data)" in html
    assert "markMyWorkStale()" in html
    assert "const sessionItems = workSession.items()" in html


@pytest.mark.anyio
async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_actions():
    html = await dashboard()

    assert '.mark-update-read' in html and 'min-height:44px' in html
    assert 'data-notification-id' in html
    assert 'Unread update' in html
    assert 'id="my-work-action-status"' in html and 'aria-live="assertive"' in html
    assert "createNotificationAcknowledger" in html
    assert "method: 'PATCH'" in html
    assert "api/v1/notifications/" in html
    assert '<a class="my-work-card"' not in html


@pytest.mark.anyio
async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
    html = await dashboard()

    assert 'id="update-sheet"' in html and 'aria-modal="true"' in html
    assert 'class="read-update"' in html
    assert 'id="keep-update-unread"' in html
    assert 'id="mark-update-read-next"' in html
    assert 'id="retry-update-load"' in html
    assert 'id="update-comments"' in html
    assert 'id="update-subject-body"' in html
    assert 'id="open-update-gitea"' in html
    assert '.update-sheet-panel { width:min(560px,100%);' in html
    assert '.update-sheet-content { overflow-wrap:anywhere;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
    assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
    assert "createNotificationReader" in html
    assert "api/v1/notifications/" in html
    assert "reader:notificationReader, items:()=>lastMyWork" in html


def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
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 statuses = [];
let calls = 0;
let fail = true;
const replier = buildMyWork.createNotificationReplier({{
  storage,
  post: async (id, body) => {{
    calls += 1;
    await new Promise(resolve => setTimeout(resolve, 5));
    if (fail) throw new Error('offline');
    return {{id:91, url:'https://forge.example/comment/91'}};
  }},
  onStatus: status => statuses.push(status),
}});
const item = {{notification_id:42}};
replier.saveDraft(item, 'Please retry.');
Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')])
  .then(async first => {{
    const afterFailure = replier.loadDraft(item);
    fail = false;
    const success = await replier.submit(item, afterFailure);
    process.stdout.write(JSON.stringify({{
      first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses,
    }}));
  }});
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["first"] == [False, False]
    assert output["afterFailure"] == "Please retry."
    assert output["success"]["id"] == 91
    assert output["afterSuccess"] == ""
    assert output["calls"] == 2
    assert output["statuses"] == [
        "Sending reply…",
        "Could not send reply. Your draft is safe; retry.",
        "Sending reply…",
        "Reply posted. You can mark this update read when ready.",
    ]


@pytest.mark.anyio
async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
    html = await dashboard()

    assert 'id="update-reply"' in html
    assert 'maxlength="10000"' in html
    assert 'id="send-update-reply"' in html
    assert 'id="update-reply-status"' in html
    assert 'aria-live="assertive"' in html
    assert '.update-reply textarea { width:100%;' in html
    assert '.update-reply button { min-height:44px;' in html
    assert '.update-sheet-header button { min-height:44px;' in html
    assert 'createNotificationReplier' in html
    assert "method: 'POST'" in html
    assert "'/reply'" in html
    assert "notificationReplier.loadDraft(item)" in html
    assert "notificationReplier.saveDraft(selectedUpdate" in html
    assert "notificationReplier.submit(selectedUpdate" in html


@pytest.mark.anyio
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
    html = await dashboard()

    assert 'id="select-updates"' in html
    assert 'id="cancel-update-selection"' in html
    assert 'id="update-selection-status"' in html
    assert 'aria-live="polite"' in html
    assert 'class="update-selector"' in html
    assert 'type="checkbox"' in html
    assert 'Select update ' in html
    assert '.update-selector { min-height:44px;' in html
    assert '.my-work-bulk { position:sticky;' in html
    assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
    assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
    assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
    assert "'Mark ' + selection.count + ' selected read'" in html
    assert "'Confirm marking ' + selection.count + ' selected read'" in html
    assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
    assert 'notificationSelection.retain(result.failed)' in html
    assert "notificationSelection.cancel()" in html
    assert "createBulkNotificationAcknowledger" in html
    assert "api/v1/notifications/read" in html
    assert "body: JSON.stringify({ ids })" in html


@pytest.mark.anyio
async def test_selected_updates_can_be_deferred_together_without_marking_them_read():
    html = await dashboard()

    assert 'id="defer-selected-today"' in html
    assert 'id="defer-selected-tomorrow"' in html
    assert 'laterWork.deferMany(selectedUpdates, until)' in html
    assert "notificationSelection.cancel()" in html
    assert "Selection unchanged." in html
    assert "work stays unread and unchanged in Gitea" in html
    assert '.my-work-bulk-actions { display:grid;' in html
    assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html


@pytest.mark.anyio
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
    html = await dashboard()
    service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()

    assert '<script src="static/work-selection.js"></script>' in html
    assert 'id="select-work"' in html
    assert 'id="batch-add-today"' in html
    assert 'createWorkSelection({ limit: 50' in html
    assert 'todayWork.addMany(selectedItems)' in html
    assert 'laterWork.deferMany(selectedItems, until)' in html
    assert 'data-select-work-id=' in html
    assert 'Select work ' in html
    assert "workSelectionState.count + ' selected'" in html
    assert '.work-selector { min-height:44px;' in html
    assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
    assert "BASE + 'static/work-selection.js'" in service_worker


@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
    html = await dashboard()

    assert 'id="notification-page-status"' in html
    assert 'id="load-more-notifications"' in html
    assert '.load-more-notifications' in html and 'min-height:44px' in html
    assert "createNotificationPager" in html
    assert "api/v1/notifications?page=" in html
    assert "snapshot.notification_pagination" in html
    assert "notificationPager.loadMore(lastNotifications)" in html
    assert "createNotificationSelection({" in html
    assert "limit: 50" in html


@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
    html = await dashboard()

    assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
    assert 'data-work-count="all"' in html
    assert 'data-work-count="today"' in html
    assert 'data-work-count="attention"' in html
    assert 'data-work-count="issue"' in html
    assert 'data-work-count="pull"' in html
    assert 'data-work-count="review"' in html
    assert 'data-work-count="update"' in html
    assert 'data-work-count="later"' in html
    assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
    assert 'data-work-count="draft"' in html
    assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
    assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html


def test_review_controller_loads_encoded_cross_repo_detail_path():
    script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let request;
const controller = createReviewController({{ fetchJson: async (url, options) => {{
  request = {{ url, accept: options.headers.Accept }};
  return {{ title: 'Review API' }};
}} }});
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
  process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
);
"""

    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "request": {
            "url": "api/v1/repos/stackchain/api/pulls/7/review",
            "accept": "application/json",
        },
        "title": "Review API",
    }


def test_review_controller_refreshes_checks_through_status_only_path():
    script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
const calls = [];
const controller = createReviewController({{ fetchJson: async (url, options) => {{
  calls.push({{url, accept:options.headers.Accept}});
  return {{head_sha:'abc123', ci_state:'success', checks:[]}};
}} }});
controller.loadChecks({{repository:'stackchain/api', number:7}}).then(status =>
  process.stdout.write(JSON.stringify({{calls, status}}))
);
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    output = json.loads(result.stdout)

    assert output["calls"] == [{
        "url": "api/v1/repos/stackchain/api/pulls/7/review/checks",
        "accept": "application/json",
    }]
    assert output["status"]["ci_state"] == "success"


def test_review_diff_rows_escape_content_and_toggle_accessibly():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
  .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const html = reviewSheet.renderDiffFile({{
  filename: 'src/<api>.py', status: 'modified', additions: 1, deletions: 1,
  diff_available: true, diff_truncated: true,
  diff_lines: ['@@ -1 +1 @@', '-old <token>', '+new & safe']
}}, 2, escapeHtml);
const button = {{ attrs: {{ 'aria-expanded': 'false' }}, getAttribute(k) {{ return this.attrs[k]; }}, setAttribute(k,v) {{ this.attrs[k]=v; }} }};
const panel = {{ hidden: true }};
reviewSheet.toggleDiff(button, panel);
process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expanded'], hidden: panel.hidden }}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert 'aria-expanded="false"' in output["html"]
    assert 'src/&lt;api&gt;.py' in output["html"]
    assert '-old &lt;token&gt;' in output["html"]
    assert '+new &amp; safe' in output["html"]
    assert 'Preview truncated' in output["html"]
    assert 'class="review-mark"' in output["html"]
    assert 'data-review-filename="src/&lt;api&gt;.py"' in output["html"]
    assert 'class="review-diff-line review-inline-target removed"' in output["html"]
    assert 'data-old-position="1"' in output["html"]
    assert 'data-new-position="1"' in output["html"]
    assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old">1</span><span class="review-line-number new"></span></span>' in output["html"]
    assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old"></span><span class="review-line-number new">1</span></span>' in output["html"]
    assert '<span class="review-line-code">-old &lt;token&gt;</span>' in output["html"]
    assert '<span class="review-line-code">+new &amp; safe</span>' in output["html"]
    assert 'aria-label="Comment on src/&lt;api&gt;.py line 1"' in output["html"]
    assert 'Mark reviewed' in output["html"]
    assert output["expanded"] == "true"
    assert output["hidden"] is False


def test_review_checks_render_blockers_first_with_safe_touch_links():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
  .replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const result = reviewSheet.renderChecks([
  {{name:'build', state:'success', description:'Passed'}},
  {{name:'lint <mobile>', state:'failure', description:'Open & fix', url:'https://forge.example/jobs/4'}},
  {{name:'release', state:'pending', description:'Waiting'}},
], escapeHtml, {{offline:false}});
const offline = reviewSheet.renderChecks([
  {{name:'lint', state:'success', description:'Passed'}},
], escapeHtml, {{offline:true}});
process.stdout.write(JSON.stringify({{result, offline}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["result"]["summary"] == "1 failed · 1 pending · 1 passed"
    assert output["result"]["expanded"] is True
    assert output["result"]["html"].index("lint &lt;mobile&gt;") < output["result"]["html"].index("build")
    assert 'href="https://forge.example/jobs/4"' in output["result"]["html"]
    assert 'target="_blank" rel="noopener noreferrer"' in output["result"]["html"]
    assert "Open job" in output["result"]["html"]
    assert output["offline"]["summary"].startswith("Last known · ")


@pytest.mark.anyio
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
    html = await dashboard()

    assert html.index('id="pull-checks"') < html.index('id="pull-files"')
    assert html.index('id="review-checks"') < html.index('id="review-files"')
    assert 'id="refresh-pull-checks"' in html
    assert 'id="refresh-review-checks"' in html
    assert "createReviewController.renderChecks" in html
    assert "pullController.loadChecks(item)" in html
    assert "reviewController.loadChecks(item)" in html
    assert "New commits detected. Reload review data before merging." in html
    assert "New commits detected. Reload the review before submitting feedback." in html
    assert "qs('#refresh-review-checks').disabled = offlineReview" in html
    assert ".ci-check-link" in html and "min-height:44px" in html
    assert ".ci-check-copy" in html and "overflow-wrap:anywhere" in html


def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
  getItem: key => values.has(key) ? values.get(key) : null,
  setItem: (key, value) => values.set(key, value),
}};
const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
const initial = phone.snapshot();
const disabled = phone.setWrapped(false);
const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
  stored: values.get('stackchain.review-wrap.v1')}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "initial": {"wrapped": True, "explicit": False},
        "disabled": {"wrapped": False, "explicit": True},
        "restoredPhone": {"wrapped": False, "explicit": True},
        "restoredDesktop": {"wrapped": False, "explicit": True},
        "stored": "false",
    }


def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const rows = reviewSheet.parseDiffLines([
  '@@ -10,3 +20,4 @@ function run()',
  ' context', '-removed', '+added', '+second',
  String.raw`\\ No newline at end of file`,
  '@@ -40 +51 @@', '-old tail', '+new tail'
]);
process.stdout.write(JSON.stringify(rows));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == [
        {"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
        {"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
        {"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
        {"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
        {"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
        {"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
        {"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
        {"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
        {"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
    ]


def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
  getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
  setItem(key, value) {{ values.set(key, value); }}
}};
const options = {{
  storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
  files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}, {{filename:'README.md'}}]
}};
const first = reviewSheet.createProgress(options);
const before = first.snapshot();
const marked = first.markReviewed('src/a.py');
const restored = reviewSheet.createProgress(options).snapshot();
process.stdout.write(JSON.stringify({{ before, marked, restored }}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )
    output = json.loads(result.stdout)

    assert output["before"] == {
        "reviewed": [], "reviewedCount": 0, "total": 3, "nextFilename": "src/a.py"
    }
    assert output["marked"] == {
        "reviewed": ["src/a.py"], "reviewedCount": 1, "total": 3,
        "nextFilename": "src/b.py",
    }
    assert output["restored"] == output["marked"]


def test_review_progress_resets_when_new_commits_change_the_head_sha():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
  getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
  setItem(key, value) {{ values.set(key, value); }}
}};
const base = {{ storage, repository: 'stackchain/api', number: 7, files: [{{filename:'src/a.py'}}] }};
reviewSheet.createProgress({{...base, headSha:'abc123'}}).markReviewed('src/a.py');
const changed = reviewSheet.createProgress({{...base, headSha:'def456'}}).snapshot();
process.stdout.write(JSON.stringify(changed));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "reviewed": [], "reviewedCount": 0, "total": 1,
        "nextFilename": "src/a.py", "newHead": True,
    }


def test_review_feedback_draft_restores_notes_summary_and_decision_for_same_head():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
  getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
  setItem(key, value) {{ values.set(key, value); }}
}};
const options = {{
  storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
  files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}]
}};
const first = reviewSheet.createDraft(options);
first.setNote('src/a.py', 'Handle the empty state.');
first.setSummary('One blocker remains.');
first.setDecision('request_changes');
const restored = reviewSheet.createDraft(options).snapshot();
process.stdout.write(JSON.stringify(restored));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "notes": {"src/a.py": "Handle the empty state."},
        "comments": [],
        "summary": "One blocker remains.",
        "decision": "request_changes",
    }


def test_review_draft_persists_edits_and_removes_inline_comments_for_same_head():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'src/a.py'}}]}};
const first = reviewSheet.createDraft(options);
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle empty values.');
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle null and empty values.');
first.setInlineComment({{path:'src/a.py', old_position:9}}, 'Why remove this guard?');
const restored = reviewSheet.createDraft(options);
const beforeRemove = restored.snapshot().comments;
const afterRemove = restored.removeInlineComment({{path:'src/a.py', old_position:9}}).comments;
process.stdout.write(JSON.stringify({{beforeRemove, afterRemove}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "beforeRemove": [
            {"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
            {"path": "src/a.py", "body": "Why remove this guard?", "old_position": 9},
        ],
        "afterRemove": [
            {"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
        ],
    }


def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const markdown = reviewSheet.formatFeedback({{
  decision: 'request_changes', summary: 'One blocker remains.',
  notes: {{'src/b.py':'Second note', 'src/a.py':'First note', 'README.md':''}}
}}, [{{filename:'src/a.py'}}, {{filename:'README.md'}}, {{filename:'src/b.py'}}]);
process.stdout.write(markdown);
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert result.stdout == (
        "## Intended decision\nRequest changes\n\n"
        "## Summary\nOne blocker remains.\n\n"
        "## File notes\n### `src/a.py`\nFirst note\n\n"
        "### `src/b.py`\nSecond note"
    )


def test_review_handoff_reserves_window_before_copy_and_reports_blocked_popup():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const events = [];
let finishCopy;
const reserved = {{ location: {{ href: '' }}, close: () => events.push('close') }};
const success = reviewSheet.copyAndContinue({{
  text: 'feedback', url: 'https://forge.example/pulls/7',
  copy: text => new Promise(resolve => {{ events.push('copy:' + text); finishCopy = resolve; }}),
  open: () => {{ events.push('reserve'); return reserved; }},
  fallback: text => events.push('fallback:' + text),
}});
const beforeCopySettles = events.slice();
finishCopy();
const blockedEvents = [];
const blocked = reviewSheet.copyAndContinue({{
  text: 'copied', url: 'https://forge.example/pulls/8',
  copy: async () => blockedEvents.push('copy'),
  open: () => {{ blockedEvents.push('reserve'); return null; }},
  fallback: text => blockedEvents.push('fallback:' + text),
}});
Promise.all([success, blocked]).then(results => process.stdout.write(JSON.stringify({{
  beforeCopySettles, events, blockedEvents, destination: reserved.location.href, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "beforeCopySettles": ["reserve", "copy:feedback"],
        "events": ["reserve", "copy:feedback"],
        "blockedEvents": ["reserve", "copy", "fallback:copied"],
        "destination": "https://forge.example/pulls/7",
        "results": [
            {"copied": True, "opened": True},
            {"copied": True, "opened": False},
        ],
    }


def test_review_handoff_closes_reserved_window_when_copy_fails():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const events = [];
reviewSheet.copyAndContinue({{
  text: 'keep me', url: 'https://forge.example/pulls/8',
  copy: async () => {{ throw new Error('denied'); }},
  open: () => ({{ close: () => events.push('close') }}),
  fallback: text => events.push('fallback:' + text),
}}).then(result => process.stdout.write(JSON.stringify({{ events, result }})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "events": ["close", "fallback:keep me"],
        "result": {"copied": False, "opened": False},
    }


@pytest.mark.anyio
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
    html = await dashboard()

    assert 'id="review-sheet"' in html
    assert 'role="dialog"' in html and 'aria-modal="true"' in html
    assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
    assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
    assert '<script src="static/review-sheet.js"></script>' in html
    assert '@media (max-width: 600px)' in html
    assert '.review-sheet-panel' in html and 'width:100%' in html
    assert '.review-action' in html and 'min-height:44px' in html
    assert '.review-file-toggle' in html and 'min-height:44px' in html
    assert '.review-mark' in html and 'min-height:44px' in html
    assert 'id="review-progress"' in html and 'aria-live="polite"' in html
    assert 'id="next-unreviewed-review"' in html
    assert '.review-progress-actions' in html and 'position:sticky' in html
    assert "createReviewController.createProgress" in html
    assert "progress.markReviewed" in html
    assert "scrollIntoView" in html
    assert '.review-diff' in html and 'overflow-x:auto' in html


@pytest.mark.anyio
async def test_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
    html = await dashboard()

    assert 'id="review-wrap-lines"' in html
    assert 'aria-pressed="false"' in html
    assert 'aria-controls="review-files"' in html
    assert "createReviewController.createWrapPreference" in html
    assert "matchMedia('(max-width: 600px)').matches" in html
    assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
    assert "wrapPreference.setWrapped" in html
    assert '.review-display-tools button' in html and 'min-height:44px' in html
    assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
    assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
    assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
    assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
    assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html


@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
    html = await dashboard()

    assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
    assert "reviewOutbox && item.status === 'attention'" in html
    assert ">Open current review</button>" in html
    assert ">Copy feedback</button>" in html
    assert "reviewOutbox && item.status === 'authorization'" in html
    assert ">Authorize &amp; send review</button>" in html
    assert "Review submitted and queued intent cleared." in html
    assert "item.kind === 'authored-outbox' && !reviewOutbox" in html


@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
    html = await dashboard()

    assert 'data-review-index' in html
    assert "reviewController.load(selectedReview)" in html
    assert "review-files" in html
    assert "review-history" in html
    assert "open-review-gitea" in html
    assert 'id="submit-review"' in html
    assert 'id="review-submit-status"' in html and 'aria-live="assertive"' in html
    assert "reviewController.submit(selectedReview" in html
    assert "window.confirm" in html
    assert "expected_head_sha: selectedReviewHead" in html
    assert "await load()" in html


@pytest.mark.anyio
async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
    html = await dashboard()
    review_script = REVIEW_SHEET.read_text()

    assert 'class="review-note"' in review_script
    assert 'id="review-decision"' in html
    assert 'id="review-summary"' in html
    assert 'id="copy-review-feedback"' in html
    assert 'id="review-copy-fallback"' in html
    assert 'id="review-handoff-link"' in html
    assert '.review-handoff-link' in html and 'min-height:44px' in html
    assert '.review-handoff-link[hidden]' in html and 'display:none' in html
    assert '.review-note' in html and 'min-height:88px' in html
    assert '.review-handoff' in html and 'position:sticky' in html
    assert "createReviewController.createDraft" in html
    assert "draft.setNote" in html
    assert "draft?.setSummary" in html
    assert "draft?.setDecision" in html
    assert "createReviewController.formatFeedback" in html
    assert "createReviewController.copyAndContinue" in html
    assert "navigator.clipboard.writeText" in html
    assert "window.open('about:blank', '_blank')" in html
    assert "handoffWindow.opener = null" in html


@pytest.mark.anyio
async def test_mobile_review_sheet_edits_inline_drafts_and_submits_them_with_review():
    html = await dashboard()

    assert 'id="review-inline-composer"' in html
    assert 'id="review-inline-body"' in html and 'maxlength="10000"' in html
    assert 'id="save-inline-comment"' in html
    assert 'id="delete-inline-comment"' in html
    assert '.review-inline-target' in html and 'min-height:44px' in html
    assert '.review-inline-composer' in html and 'position:sticky' in html
    assert "document.querySelectorAll('.review-inline-target')" in html
    assert "draft.setInlineComment" in html
    assert "draft.removeInlineComment" in html
    assert "comments: snapshot.comments" in html


def test_review_controller_submits_once_while_request_is_in_flight():
    script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let resolveRequest;
const calls = [];
const controller = createReviewController({{ createOperationId: () => 'review-op-test', fetchJson: (url, options) => {{
  calls.push({{url, options}});
  return new Promise(resolve => {{ resolveRequest = resolve; }});
}} }});
const item = {{repository:'stackchain/api', number:7}};
const payload = {{decision:'approve', body:'Looks good.', expected_head_sha:'abc123'}};
const first = controller.submit(item, payload);
const second = controller.submit(item, payload);
resolveRequest({{id:91, state:'APPROVED'}});
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
  calls, results
}})));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    payload = json.loads(result.stdout)
    assert len(payload["calls"]) == 1
    assert payload["calls"][0] == {
        "url": "api/v1/repos/stackchain/api/pulls/7/review",
        "options": {
            "method": "POST",
            "headers": {
                "Accept": "application/json",
                "Content-Type": "application/json",
                "Idempotency-Key": "review-op-test",
            },
            "body": json.dumps(
                {
                    "decision": "approve",
                    "body": "Looks good.",
                    "expected_head_sha": "abc123",
                },
                separators=(",", ":"),
            ),
        },
    }
    assert payload["results"] == [
        {"id": 91, "state": "APPROVED"},
        {"id": 91, "state": "APPROVED"},
    ]


def test_successful_review_can_clear_head_scoped_draft_and_progress():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const removed = [];
const storage = {{
  getItem: key => values.get(key) || null,
  setItem: (key, value) => values.set(key, value),
  removeItem: key => {{ removed.push(key); values.delete(key); }},
}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'a.py'}}]}};
const draft = reviewSheet.createDraft(options);
const progress = reviewSheet.createProgress(options);
draft.setSummary('Looks good.');
progress.markReviewed('a.py');
draft.clear();
progress.clear();
process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:progress.snapshot()}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    payload = json.loads(result.stdout)
    assert payload["removed"] == [
        "stackchain.review-draft.v1:stackchain/api#7@abc123",
        "stackchain.review-progress.v1:stackchain/api#7@abc123",
    ]
    assert payload["draft"] == {
        "notes": {}, "comments": [], "summary": "", "decision": "comment"
    }
    assert payload["progress"]["reviewedCount"] == 0


def test_approved_assigned_review_carries_exact_head_progress_into_merge():
    script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
  getItem: key => values.get(key) || null,
  setItem: (key, value) => values.set(key, value),
}};
const assigned = {{
  repository:'stackchain/api', number:7,
  work_reasons:['assigned_to_me', 'review_requested']
}};
const carried = reviewSheet.prepareMergeContinuation({{
  storage, item:assigned, headSha:'abc123',
  reviewed:['a.py', 'b.py'], decision:'approve'
}});
const unassigned = reviewSheet.prepareMergeContinuation({{
  storage, item:{{...assigned, number:8, work_reasons:['review_requested']}},
  headSha:'def456', reviewed:['c.py'], decision:'approve'
}});
const changesRequested = reviewSheet.prepareMergeContinuation({{
  storage, item:{{...assigned, number:9}}, headSha:'ghi789',
  reviewed:['d.py'], decision:'request_changes'
}});
process.stdout.write(JSON.stringify({{carried, unassigned, changesRequested, entries:[...values.entries()]}}));
"""
    result = subprocess.run(
        ["node", "-e", script], check=True, capture_output=True, text=True
    )

    assert json.loads(result.stdout) == {
        "carried": True,
        "unassigned": False,
        "changesRequested": False,
        "entries": [[
            "stackchain.pull-review.v1:stackchain/api#7:abc123",
            '["a.py","b.py"]',
        ]],
    }


@pytest.mark.anyio
async def test_successful_assigned_approval_continues_to_merge_in_place():
    html = await dashboard()

    assert 'id="continue-review-to-merge"' in html
    assert '#continue-review-to-merge[hidden]' in html and 'display:none' in html
    assert '.review-merge-continuation' in html and 'position:sticky' in html
    assert '#continue-review-to-merge { min-height:44px;' in html
    assert "createReviewController.prepareMergeContinuation" in html
    assert "reviewed: progressSnapshot.reviewed" in html
    assert "decision: snapshot.decision" in html
    assert "workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true })" in html
    assert "qs('#continue-review-to-merge').hidden = false" in html
    assert "qs('#continue-review-to-merge').focus()" in html


@pytest.mark.anyio
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
    html = await dashboard()

    assert 'id="retry-review-load"' in html
    assert '.review-retry' in html and 'min-height:44px' in html
    assert "qs('#retry-review-load').hidden = false" in html
    assert "qs('#retry-review-load').hidden = true" in html
    assert "openReviewSheet(selectedReview, reviewTrigger)" in html
    assert "qs('#retry-review-load').focus()" in html


@pytest.mark.anyio
async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation():
    html = await dashboard()

    update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')]
    assert '<h2>Full conversation</h2>' in update_sheet
    assert 'id="update-comments"' in update_sheet
    assert 'id="load-older-update-comments"' in update_sheet
    assert 'id="update-conversation-status"' in update_sheet
    assert '.conversation-more' in html and 'min-height:44px' in html
    assert "'/conversation?limit=20' + pageQuery" in html
    assert 'id="retry-update-conversation"' in update_sheet
    assert "notificationReader.retryConversation()" in html
    assert "loadConversation: fetchNotificationConversation" in html
    assert "onConversation: renderUpdateConversation" in html
    assert "notificationReader.loadOlder()" in html
    assert "notificationReader.appendReply(result)" in html


def test_issue_comment_reuses_operation_key_after_reload_until_success():
    script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const first = createIssueSheet({{storage, createOperationId:() => 'issue-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.comment(item, 'Ship it').catch(() => {{
  const second = createIssueSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:82}});}}}});
  second.comment(item, 'Ship it').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {"calls": ["issue-op-185", "issue-op-185"], "keys": []}


def test_pull_comment_reuses_operation_key_after_reload_until_success():
    script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const first = createPullSheet({{storage, createOperationId:() => 'pull-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.comment(item, 'Ship it').catch(() => {{
  createPullSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:91}});}}}}).comment(item, 'Ship it')
    .then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {"calls": ["pull-op-185", "pull-op-185"], "keys": []}


def test_notification_reply_reuses_persisted_operation_key():
    script = f"""
const build = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{notification_id:42}};
const first = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'reply-op-185', post:(_id,_body,key) => {{calls.push(key); return Promise.reject(new Error('timeout'));}}}});
first.submit(item, 'Retry').then(() => {{
  const second = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'wrong-key', post:(_id,_body,key) => {{calls.push(key); return Promise.resolve({{id:91}});}}}});
  second.submit(item, 'Retry').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {"calls": ["reply-op-185", "reply-op-185"], "keys": []}


def test_review_submission_reuses_persisted_operation_key():
    script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const payload = {{decision:'approve', body:'Good', expected_head_sha:'abc', comments:[]}};
const first = createReviewController({{storage, createOperationId:() => 'review-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.submit(item, payload).catch(() => {{
  createReviewController({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:93}});}}}}).submit(item, payload)
    .then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}

def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings():
    payload = {
        "user": {"login": "timmy"},
        "issues": [
            {"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]},
            {"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]},
            {"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]},
        ],
        "pull_requests": [],
    }
    script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify({{
  filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b),
  count: buildMyWork.countMyWork(items).filed,
  identities: items.map(item => item.key).sort(),
}}));
"""
    result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    assert json.loads(result.stdout) == {
        "filed": [1, 2], "count": 2,
        "identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"],
    }
