mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
static/style.css is 51,425 lines and holds every panel's styles, so two people working on unrelated panels still edit the same file. This is the first of three steps that give each panel group its own stylesheet. It moves the document library, image gallery and image editor rules that can move into static/css/documents-gallery-editor.css. The new file is loaded eagerly from index.html immediately after style.css, so the cascade is the concatenation of the two in that order, which is the order those rules already had. Nothing is deferred and nothing renders differently. Rules moved verbatim. A rule stays in style.css when its selector group covers more than one panel, when the class is shared design language used from modules outside the panel, or when moving it would flip which of two equally specific rules wins on an element the markup puts both classes on. That last rule is what keeps this change small: most panel-prefixed rules on this branch compete with a shared component rule somewhere later in the file. Nineteen tests read static/style.css directly and go red the moment a rule they assert on moves, which says nothing about the page. They now go through tests/helpers/stylesheets.app_css(), which concatenates the stylesheets in the order index.html loads them. The helper also exposes the <link> markup for tests that build a synthetic page through Playwright.
411 lines
20 KiB
Python
411 lines
20 KiB
Python
from pathlib import Path
|
|
import subprocess
|
|
|
|
from tests.helpers.stylesheets import app_css
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def test_shared_action_menu_order_is_used_by_item_menus() -> None:
|
|
expected_imports = {
|
|
"static/js/documentLibrary.js": "orderActionMenuItems",
|
|
"static/js/tasks.js": "orderActionMenuItems",
|
|
"static/js/sessions.js": "orderActionMenuItems",
|
|
"static/js/research/panel.js": "orderActionMenuItems",
|
|
"static/js/emailLibrary.js": "orderActionMenuItems",
|
|
"static/js/memory.js": "orderActionMenuItems",
|
|
}
|
|
for relative_path, helper in expected_imports.items():
|
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert "actionMenuOrder.js" in source
|
|
assert helper in source
|
|
|
|
|
|
def test_common_action_order_matches_product_convention() -> None:
|
|
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
|
|
for rank in (200, 400, 500, 550, 600, 650, 700, 900, 1000):
|
|
assert f"{{ rank: {rank}" in source
|
|
|
|
|
|
def test_callback_actions_are_sorted_by_their_labels() -> None:
|
|
script = """
|
|
import { orderActionMenuItems } from './static/js/actionMenuOrder.js';
|
|
const callback = () => {};
|
|
const items = [
|
|
{ label: 'Delete', action: callback },
|
|
{ label: 'Archive', action: callback },
|
|
{ label: 'Copy', action: callback },
|
|
{ label: 'Favorite', action: callback },
|
|
{ label: 'Select', action: callback },
|
|
{ label: 'Rename', action: callback },
|
|
{ label: 'Open', action: callback },
|
|
{ label: 'Cancel', action: callback },
|
|
];
|
|
process.stdout.write(orderActionMenuItems(items).map(item => item.label).join('|'));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "--input-type=module", "--eval", script],
|
|
cwd=ROOT,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.stdout == "Open|Rename|Favorite|Copy|Select|Archive|Delete|Cancel"
|
|
|
|
|
|
def test_action_order_module_is_precached() -> None:
|
|
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
|
assert "'/static/js/actionMenuOrder.js'" in service_worker
|
|
|
|
|
|
def test_dropdown_select_actions_use_the_canonical_icon() -> None:
|
|
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
|
|
assert "export const SELECT_MENU_ICON" in source
|
|
for relative_path in (
|
|
"static/js/documentLibrary.js",
|
|
"static/js/memory.js",
|
|
"static/js/sessions.js",
|
|
"static/js/skills.js",
|
|
"static/js/tasks.js",
|
|
"static/js/emailLibrary.js",
|
|
"static/js/research/panel.js",
|
|
):
|
|
module = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert "SELECT_MENU_ICON" in module
|
|
|
|
|
|
def test_email_filter_menu_has_context_title() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert 'email-filter-menu-title">Filter by...</div>' in source
|
|
|
|
|
|
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
style = app_css()
|
|
assert 'email-settings-auto-reply-section' in source
|
|
assert 'email-settings-display-enabled-state' in source
|
|
assert 'stateLabel = section?.querySelector' in source
|
|
assert '.email-settings-section.is-disabled .email-settings-enabled-state' in style
|
|
|
|
|
|
def test_email_search_options_menu_has_context_title() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
menu_start = source.index('id="email-search-options-menu"')
|
|
menu_end = source.index("</div>", menu_start) + len("</div>")
|
|
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
|
|
|
|
|
|
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _emailTimelineGapThreshold(items)" in source
|
|
assert "email-date-gap-break" in source
|
|
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
|
|
|
|
style = app_css()
|
|
assert ".date-section-header.email-date-gap-break" in style
|
|
|
|
|
|
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert '<option value="tag:action-needed">' not in source
|
|
assert "filter:tag:action-needed" not in source
|
|
assert "email-card-favorite" in source
|
|
assert "aria-pressed" in source
|
|
assert "/api/email/flag/" in source
|
|
assert "Object.prototype.hasOwnProperty.call(em, 'is_flagged')" in source
|
|
assert "const favoritesView = state._libFilter === 'favorites';" in source
|
|
assert "statusCluster.insertBefore(favoriteToggle, doneControl)" in source
|
|
assert "statusCluster.className = 'email-card-status';" in source
|
|
assert "statusCluster.appendChild(att)" in source
|
|
assert "statusCluster.appendChild(doneCheck)" in source
|
|
assert "function _exactTypedFilterSuggestion(value)" in source
|
|
assert "opt.value === 'filter:has-attachments'" in source
|
|
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
|
|
assert "_acceptSuggestion(typedFilter);" in source
|
|
|
|
style = app_css()
|
|
favorite_start = style.index(".email-card-favorite {")
|
|
favorite_end = style.index("}", favorite_start) + 1
|
|
assert "top: -3px;" in style[favorite_start:favorite_end]
|
|
assert ".email-card-status" in style
|
|
|
|
|
|
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _todayDateInputValue()" in source
|
|
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
|
|
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
|
|
assert "autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);" in source
|
|
|
|
|
|
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
|
|
assert "summary: 'Email Auto Reply (away)'" in source
|
|
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
|
|
assert "Odysseus email auto reply - account:" in source
|
|
assert "_AUTO_REPLY_CALENDAR_KEY_PREFIX" in source
|
|
assert "method: 'POST', body: JSON.stringify(payload)" in source
|
|
assert "method: 'PUT', body: JSON.stringify(payload)" in source
|
|
assert "method: 'DELETE'" in source
|
|
assert "all_day: true" in source
|
|
assert "await _syncAutoReplyCalendarEvent(savedCfg)" in source
|
|
assert "_syncAutoReplyCalendarEvent(cfg).catch" in source
|
|
|
|
|
|
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
style = app_css()
|
|
assert 'email-account-away-label">(AWAY)</span>' in source
|
|
assert 'id="email-lib-auto-reply-badge"' in source
|
|
assert ">Show Email Tags</span>" in source
|
|
assert "enabled ? 'Show' : 'Hide'" in source
|
|
assert "email-settings-inline-link" in source
|
|
assert "email-auto-reply-exclude" not in source
|
|
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg)" in source
|
|
assert ".email-settings-status.is-success" in style
|
|
assert "var(--color-success, #4caf50)" in style
|
|
assert ".email-style-settings-extract svg" in style
|
|
assert "export async function mountEmailSettings(host)" in source
|
|
assert "_openGlobalEmailSettings('show-tags')" in source
|
|
assert source.count('class="admin-card email-settings-section') == 5
|
|
assert 'id="settings-email-default-card"' in (ROOT / "static/index.html").read_text(encoding="utf-8")
|
|
assert "multipleAccounts" in source
|
|
|
|
|
|
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
|
|
assert "email-settings-clean-btn" in cleanup
|
|
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
|
|
|
|
|
|
def test_email_settings_escape_returns_to_email_list() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
|
|
assert settings_guard in source
|
|
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
|
|
assert "_hideEmailSettingsPage();" in source[source.index(settings_guard):source.index(settings_guard) + 180]
|
|
|
|
|
|
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
select_guard = "if (state._selectMode) {"
|
|
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
|
|
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
|
|
assert "closeEmailLibrary();" not in source[select_start:select_start + 260]
|
|
|
|
|
|
def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
|
|
source = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
|
assert "const TRASH_ICON =" in source
|
|
assert "{ id: 'delete', icon: TRASH_ICON" in source
|
|
assert source.count("{ id: 'delete', icon: TRASH_ICON") == 2
|
|
assert "M3 6h18" in source
|
|
|
|
|
|
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _askAgentToUnsubscribe")
|
|
end = source.index("function _unsubscribeCandidateUids", start)
|
|
prompt = source[start:end]
|
|
assert "private_browser" in prompt
|
|
assert "Do not call scan_email_unsubscribes again" in prompt
|
|
assert "Reviewed method_index" in prompt
|
|
assert "Exact unsubscribe URL" in prompt
|
|
assert "bulk_email action=delete" in prompt
|
|
assert "Email UID(s)" in prompt
|
|
|
|
|
|
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _bindEmailSettingsPageControls")
|
|
end = source.index("function _setUnsubButtonBusy", start)
|
|
controls = source[start:end]
|
|
assert "_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true })" in controls
|
|
assert "statusEl.style.justifyContent = 'flex-end'" in source
|
|
|
|
|
|
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
stylesheet = app_css()
|
|
assert "email-unsub-duplicate-badge" in frontend
|
|
start = stylesheet.index(".email-unsub-duplicate-badge {")
|
|
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
|
|
|
|
|
|
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
stylesheet = app_css()
|
|
start = frontend.index("function _emailCleanupSettingsHtml")
|
|
end = frontend.index("function _emailDisplaySettingsHtml", start)
|
|
cleanup = frontend[start:end]
|
|
assert "email-settings-clean-actions" in cleanup
|
|
assert cleanup.index("email-settings-clean-status") < cleanup.index("email-settings-clean-btn")
|
|
assert "inlineHost.querySelector('.email-settings-clean-status')" in frontend
|
|
css_start = stylesheet.index(".email-settings-clean-status {")
|
|
assert "width: auto !important;" in stylesheet[css_start:stylesheet.index("}", css_start) + 1]
|
|
assert "email-unsub-panel-status" in frontend
|
|
assert "modal.style.cssText = 'display:none;margin-top:10px;'" in frontend
|
|
assert "showFinalStatus(finalStatus)" in frontend
|
|
assert "email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'" in frontend
|
|
assert "statusEl.classList.add('is-busy')" in frontend
|
|
assert ".email-settings-clean-actions:has(.email-settings-clean-status.is-busy)" in stylesheet
|
|
panel_css = stylesheet[stylesheet.index(".email-unsub-panel-status {"):]
|
|
assert "top: 2px;" in panel_css[:panel_css.index("}") + 1]
|
|
|
|
|
|
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
|
|
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
|
|
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
|
assert "async function _deleteAfterUnsubscribe" in frontend
|
|
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
|
|
execute = backend[backend.index('@router.post("/unsubscribe/execute")'):]
|
|
assert 'deleted = _move_email_message(conn, uid, "Trash", role="trash")' in execute
|
|
assert '"deleted": deleted' in execute
|
|
unsubscribe = mcp[mcp.index("def _unsubscribe_email"):mcp.index("def _extract_text", mcp.index("def _unsubscribe_email"))]
|
|
assert "_delete_email(uid, folder=folder, account=account)" in unsubscribe
|
|
assert '"deleted": deleted' in unsubscribe
|
|
|
|
|
|
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _agentDeletedEmailUids")
|
|
end = source.index("function _handleAgentEmailToolOutput", start)
|
|
resolver = source[start:end]
|
|
assert "tool.includes('bulk_email')" in resolver
|
|
assert "tool.endsWith('delete_email')" in resolver
|
|
assert "source email moved to trash" in resolver
|
|
assert "data.uid" in resolver
|
|
|
|
|
|
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
|
|
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
|
|
start = source.index("function _agentBrowserUnsubscribeSucceeded")
|
|
end = source.index("function _agentDeletedEmailUids", start)
|
|
browser_flow = source[start:end]
|
|
assert "already\\s+unsubscribed" in browser_flow
|
|
assert "_deleteAfterUnsubscribe([candidate])" in browser_flow
|
|
assert "scope: group.sender ? 'sender_unsubscribe'" in source
|
|
assert "cleanupInFlight" in browser_flow
|
|
|
|
|
|
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
|
|
source = app_css()
|
|
start = source.index(".email-unsub-auto-safe-btn {")
|
|
assert "height: 29px;" in source[start:source.index("}", start) + 1]
|
|
|
|
|
|
def test_email_mutation_tool_events_include_exact_arguments() -> None:
|
|
source = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
|
|
start = source.index("# Emit tool_output (include ui_event data if present)")
|
|
end = source.index("if tool_call_id:", start)
|
|
event = source[start:end]
|
|
assert '"mcp__email__bulk_email"' in event
|
|
assert '"mcp__email__delete_email"' in event
|
|
assert '"mcp__email__unsubscribe_email"' in event
|
|
assert '"private_browser"' in event
|
|
assert 'tool_output_data["tool_args"]' in event
|
|
|
|
|
|
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
|
|
source = (ROOT / "routes" / "email_routes.py").read_text()
|
|
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
|
|
assert 'scope == "sender_unsubscribe"' in cleanup
|
|
assert "_unsubscribe_sender_uids_sync" in cleanup
|
|
sender_scan = source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
|
|
assert "FROM {_imap_search_quote(sender_key)}" in sender_scan
|
|
assert 'candidate.get("from_address")' in source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
|
|
|
|
|
|
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
start = source.index("function _markUnsubscribeCardDone")
|
|
end = source.index("async function _runUnsubscribeCleanup", start)
|
|
card = source[start:end]
|
|
assert "_UNSUB_CHECK_ICON" in card
|
|
assert "is-unsubscribed" in card
|
|
assert "email-unsub-scan-further" in source
|
|
|
|
|
|
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
styles = app_css()
|
|
assert "email-unsub-ignore-btn" in source
|
|
assert "_rememberUnsubscribeIgnored(c)" in source
|
|
assert "Ignore this unsubscribe candidate" in source
|
|
assert ".email-unsub-ignore-btn" in styles
|
|
|
|
|
|
def test_email_settings_sections_use_static_headers() -> None:
|
|
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
|
|
styles = app_css()
|
|
assert 'class="email-unsub-accent-icon"' in source
|
|
assert 'M12 0L14.59 8.41' in source
|
|
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
|
|
assert 'email-settings-clean-btn' in source
|
|
assert 'const inlineHost = settingsPage?.querySelector?.(\'.email-settings-cleanup-section\')' in source
|
|
assert '<div class="admin-card email-settings-section' in source
|
|
assert 'class="email-settings-section-head"' in source
|
|
assert '<div id="email-settings-cleanup-body" class="email-settings-section-body">' in source
|
|
assert '<details class="admin-card email-settings-section' not in source
|
|
assert source.count('id="email-auto-reply-enabled"') == 1
|
|
assert source.count('id="email-settings-show-tags"') == 1
|
|
assert 'flex: 0 0 auto;' in styles
|
|
assert 'page.querySelectorAll(\'details.email-settings-section\')' not in source
|
|
assert 'other.open = false' not in source
|
|
assert '.email-settings-section[open] > .email-settings-section-body' in styles
|
|
assert '.email-settings-section[open] > .email-settings-section-body > *' in styles
|
|
assert 'flex: 0 0 auto;' in styles
|
|
assert '.email-settings-section[open] {' in styles
|
|
assert 'flex: 1 1 auto;' in styles
|
|
assert 'grid-template-rows: auto minmax(0, 1fr);' in styles
|
|
assert 'height: 100%;' in styles
|
|
assert 'max-height: 100%;' in styles
|
|
assert 'overflow: hidden;' in styles
|
|
assert '.modal-content:not([style*="height"])' in styles
|
|
assert 'left: -1px;' in styles
|
|
assert '.email-settings-clean-status:not(:empty)' in styles
|
|
assert '.email-unsub-status.is-error' in styles
|
|
|
|
|
|
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
|
|
backend = (ROOT / "routes" / "email_routes.py").read_text()
|
|
schema = (ROOT / "src" / "tool_schemas.py").read_text()
|
|
agent = (ROOT / "src" / "agent_loop.py").read_text()
|
|
scan_start = backend.index('@router.get("/unsubscribe/scan")')
|
|
scan_end = backend.index('@router.post("/unsubscribe/execute")', scan_start)
|
|
scan_route = backend[scan_start:scan_end]
|
|
assert 'max_scan: int = Query(500)' in scan_route
|
|
assert 'max_scan = max(limit, min(requested_max_scan or 500, 500))' in backend
|
|
assert 'for start in range(0, len(uids), 100)' in backend
|
|
assert 'capped at 500' in schema
|
|
assert '"max_scan": 500' in agent[agent.index('def _parse_qwen_explicit_unsubscribe_scan_request'):agent.index('def _parse_qwen_explicit_unsubscribe_email_request')]
|
|
mcp = (ROOT / "mcp_servers" / "email_server.py").read_text()
|
|
mcp_scan = mcp[mcp.index('def _scan_unsubscribe_candidates'):mcp.index('def _unsubscribe_email')]
|
|
assert 'max_scan=500' in mcp_scan
|
|
assert 'for start in range(0, len(uids), 100)' in mcp_scan
|
|
assert 'capped at 500' in mcp
|
|
assert 'Scan up to 500 newest email headers' in mcp
|
|
|
|
|
|
def test_item_menus_share_the_standard_dropdown_classes() -> None:
|
|
expected = {
|
|
"static/js/documentLibrary.js": "dropdown session-dropdown-menu doclib-card-dropdown",
|
|
"static/js/memory.js": "dropdown session-dropdown-menu memory-item-dropdown",
|
|
"static/js/tasks.js": "dropdown session-dropdown-menu task-dropdown",
|
|
"static/js/skills.js": "dropdown session-dropdown-menu skill-kebab-menu",
|
|
}
|
|
for relative_path, class_names in expected.items():
|
|
source = (ROOT / relative_path).read_text(encoding="utf-8")
|
|
assert class_names in source
|
|
|
|
|
|
def test_task_card_menu_can_enter_select_mode_with_current_task() -> None:
|
|
source = (ROOT / "static/js/tasks.js").read_text(encoding="utf-8")
|
|
assert "function _taskEnterSelectWith(taskId)" in source
|
|
assert "label: 'Select'" in source
|
|
assert "action: () => _taskEnterSelectWith(task.id)" in source
|