Files
dodo/frontend/src/style.test.ts
T
bboysoul 5b2f599243
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m28s
feat: keep subtasks in task details
2026-09-11 16:09:04 +08:00

1264 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { createCompletionPulse, completionExitDelay, shouldAnimateCompletionExit } from './lib/completion-motion'
const css = readFileSync('src/style.css', 'utf8')
const app = readFileSync('src/App.vue', 'utf8')
const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8')
const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8')
const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8')
describe('unified task due display', () => {
it('uses the shared display for overdue and ordinary parent task rows', () => {
expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'")
expect(app.match(/<TaskDueDisplay/g)).toHaveLength(2)
expect(app).toContain(':due-at="node.task.due_at"')
expect(app).not.toContain(':due-at="subtask.due_at"')
expect(app).not.toContain('formatDue(')
expect(app).not.toContain('overdue-badge')
expect(taskDueDisplay).not.toMatch(/setInterval|setTimeout/)
})
it('keeps overdue subtasks out of the list while preserving their parent progress summary', () => {
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
expect(overdue).not.toContain('v-for="subtask in node.subtasks"')
expect(overdue).not.toContain('selectTaskUnlessSwiped(subtask)')
expect(overdue).toContain('node.subtasks.filter(t=>t.completed).length')
expect(overdue).toContain('{{node.subtasks.length}}')
})
it('places every visible-list due display in a right tail before stable actions', () => {
expect(app.match(/<span v-if="[^\"]+\.due_at" class="task-tail"><TaskDueDisplay/g)).toHaveLength(2)
expect(app).not.toContain('class="meta"><TaskDueDisplay')
expect(app).toContain('</div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay')
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
expect(app).not.toContain('restoreTask(subtask)')
expect(app).not.toContain('purgeTask(subtask)')
expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost task-detail-trigger"')
expect(app).toContain('<span v-if="node.task.priority" class="priority"')
expect(app).toContain('v-if="node.subtasks.length" class="meta"')
expect(app).toContain('<span class="meta-item"><ListChecks/>')
})
it('uses a right-aligned desktop tail and a compact two-line mobile tail without empty slots', () => {
expect(css).toContain('.task-tail{flex:0 1 auto;min-width:0;max-width:')
expect(css).toContain('text-align:right;white-space:nowrap')
expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}')
expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}')
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed) .task-main{padding:4px 0}')
expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;')
expect(css).toContain('.task-due--timed .task-due__absolute,.task-due--timed .task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}')
expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;')
expect(css).toContain('.task-check{width:44px;height:44px;')
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
})
it('keeps date-only rows compact on mobile while preserving the timed two-line layout', () => {
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;')
expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}')
expect(css).not.toContain('.task-row:has(>.task-tail){height:66px;max-height:68px;}')
expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;')
expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
})
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
expect(css).toContain('.task-due--overdue{color:var(--danger)}')
expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}')
expect(css).toContain('.task-main{border:0;background:transparent;flex:1;min-width:0;')
expect(css).toContain('.meta-item{display:flex;align-items:center;gap:3px}')
expect(css).not.toContain('.meta span{display:flex;')
expect(taskDueDisplay).not.toContain('CalendarDays')
expect(taskDueDisplay).toContain('<time')
expect(taskDueDisplay).toContain(':aria-label="presentation.fullText"')
expect(taskDueDisplay).toContain('v-if="dueHasTime" class="task-due__separator"')
expect(css).not.toContain('.overdue-task .meta')
expect(css).not.toContain('.overdue-badge')
})
})
describe('mobile navigation styles', () => {
it('renames the bottom More tab to a direct Settings tab', () => {
expect(app).not.toContain('aria-controls="mobile-more-menu"')
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
expect(app).toContain("<Settings/><span>设置</span>")
expect(app).toContain("@click=\"switchView('settings')\"")
expect(app).toContain('<span>设置</span>')
})
it('marks only exact mobile destinations active and exposes aria-current only there', () => {
for (const view of ['today', 'habits', 'countdowns', 'settings']) {
expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
}
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
})
it('keeps the mobile More sheet visible when it is rendered', () => {
expect(css).not.toContain('.more-mask{display:none}')
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).toContain('.more-mask{position:fixed;z-index:45;')
})
it('lets the mobile sidebar scrim cover the outside area and stay below the sidebar', () => {
expect(app).toContain('<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />')
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
expect(css).toContain('.scrim{position:fixed;z-index:35;inset:0;')
expect(css).toMatch(/\.scrim\{position:fixed;z-index:35;[^}]*display:block/)
})
})
describe('settings sessions and audit activity', () => {
it('renders readable session metadata, local time, and an accessible revoke action', () => {
expect(mvpPanel).toContain('class="session-copy"')
expect(mvpPanel).toContain('class="session-title"')
expect(mvpPanel).toContain('class="session-meta"')
expect(mvpPanel).toContain('class="session-device"')
expect(mvpPanel).toContain(':datetime="s.last_seen_at ?? s.created_at"')
expect(mvpPanel).toContain(':aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`"')
expect(mvpPanel).toContain('class="danger-text session-revoke"')
expect(mvpPanel).toContain('class="session-card-actions"')
expect(mvpPanel).toContain('撤销其他所有会话')
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')")
expect(css).toContain('.session-card-actions{')
})
it('does not expose the raw user agent in session DOM attributes', () => {
expect(mvpPanel).not.toContain(':title="s.user_agent || undefined"')
expect(mvpPanel).not.toMatch(/<span class="session-device"[^>]*s\.user_agent[^>]*>/)
expect(mvpPanel).toContain('<span class="session-device">{{ formatUserAgent(s.user_agent) }}</span>')
})
it('renders localized audit copy and semantic local time', () => {
expect(mvpPanel).toContain('class="audit-copy"')
expect(mvpPanel).toContain('class="audit-action"')
expect(mvpPanel).toContain("formatAuditAction(row.action ?? row.event)")
expect(mvpPanel).toContain('formatAuditEntity(row.entity_type)')
expect(mvpPanel).toContain(':datetime="row.created_at ?? row.timestamp"')
})
it('keeps revoke and all existing drag handles at 44px without enlarging icons', () => {
expect(css).toContain('.drag-handle{width:44px;min-width:44px;height:44px;flex:0 0 44px;')
expect(css).toContain('.drag-handle svg{width:17px;height:17px}')
expect(css).not.toContain('.drag-handle{width:36px;flex-basis:36px}')
expect(css).toContain('.session-copy{min-width:0;flex:1;')
expect(css).toContain('.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;')
})
})
describe('solid cream material system', () => {
it('defines the approved opaque cream surfaces, text colors, warm border, and status colors', () => {
expect(css).toContain('--surface-canvas:#f3ecdf')
expect(css).toContain('--surface-base:#fdfaf3')
expect(css).toContain('--surface-raised:#fffdf8')
expect(css).toContain('--text-primary:#302a24')
expect(css).toContain('--text-secondary:#655b50')
expect(css).toContain('--border-cream:#e4d5c0')
expect(css).toContain('--accent:#f15a29')
expect(css).toContain('--success:#687e61')
expect(css).toContain('--radius-list:15px')
expect(css).toContain('font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC"')
expect(css).toContain('font-variant-numeric:tabular-nums')
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)')
expect(css).toContain('.habit-progress-bar::-moz-progress-bar{background:var(--success)')
expect(css).not.toContain('linear-gradient(90deg,#f7a066,var(--accent))')
expect(css).toContain('--highlight-inner:inset 0 1px 0 #fff')
expect(css).toContain('--radius-control:11px')
expect(css).toContain('--radius-card:14px')
expect(css).toContain('--radius-panel:20px')
})
it('removes blur, radial glow, and legacy liquid-glass tokens', () => {
expect(css).not.toContain('backdrop-filter')
expect(css).not.toContain('radial-gradient')
expect(css).not.toContain('--glass-surface')
expect(css).not.toContain('Warm Liquid Glass')
})
it('uses opaque cream surfaces for the shell, navigation, overlays, and feedback', () => {
expect(css).toContain('.shell{background:var(--surface-base)}')
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
expect(css).toContain('main{background:var(--surface-base)}')
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
expect(css).toContain('.toast{background:#3b342c')
expect(css).toContain('.error-toast{background:var(--danger)')
expect(css).toContain('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
})
it('keeps compact continuous lists without per-row outer shadows', () => {
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{gap:0')
expect(css).toContain('.task-row,.habit-row,.countdown-row{background:var(--surface-raised);box-shadow:none')
expect(css).toContain('.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)')
})
it('keeps controls solid, focus visible, mobile targets safe, and motion reducible', () => {
expect(css).toContain('input,select,textarea,.search{background:var(--surface-raised)')
expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)')
expect(css).toContain('@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));')
expect(css).toContain('min-height:44px')
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
})
})
describe('approved UI detail direction', () => {
it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"')))
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
expect(ordinaryRows).not.toContain('selectTask(subtask)')
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
expect(ordinaryRows).toContain('restoreTask(node.task)')
expect(ordinaryRows).toContain('purgeTask(node.task)')
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
expect(overdue).not.toContain('aria-label="打开任务详情"')
})
it('keeps the parent due tail immediately before the More detail entry', () => {
expect(app).toContain('</span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>')
expect(app).not.toContain('@click.stop="selectTask(subtask)"><Ellipsis/>')
expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}')
})
it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => {
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:12px}')
expect(css).toContain('.detail-form>label{grid-template-columns:80px 1fr;align-items:center}')
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
})
it('keeps refresh neutral and at least 44px', () => {
expect(css).toContain('.topbar-refresh{width:44px;height:44px;min-width:44px;border-radius:50%;color:var(--text-secondary)}')
expect(css).not.toContain('.topbar-refresh:hover:not(:disabled){background:#fff7eb;color:var(--accent)}')
})
})
describe('completion feedback motion', () => {
it('delays removal only when a completed row will become hidden', () => {
expect(shouldAnimateCompletionExit({ completing: true, showCompleted: false })).toBe(true)
expect(shouldAnimateCompletionExit({ completing: true, showCompleted: true })).toBe(false)
expect(shouldAnimateCompletionExit({ completing: false, showCompleted: false })).toBe(false)
expect(completionExitDelay(false)).toBe(320)
expect(completionExitDelay(true)).toBe(0)
})
it('keeps completed task and habit rows mounted for the exit motion', () => {
expect(app).toContain('completion-exiting')
expect(mvpPanel).toContain('completion-exiting')
expect(css).toContain('.task-row.completion-exiting,.habit-row.completion-exiting{')
expect(css).toContain('@keyframes completion-slide-out')
})
it('animates only transient just-completed rows and respects reduced motion', () => {
expect(css).toContain('.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}')
expect(css).toContain('.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}')
expect(css).not.toContain('.task-row.done,.habit-row.done{animation:')
expect(css).toContain('@keyframes completion-row-settle')
expect(css).toContain('@keyframes completion-check-pop')
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}')
})
it('syncs the browser IANA timezone before loading user task data', () => {
expect(app).toContain("Intl.DateTimeFormat().resolvedOptions().timeZone")
expect(app).toContain("await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })")
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
expect(bootstrapBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
const authBlock = app.slice(app.indexOf('async function submitAuth()'), app.indexOf('async function loadTaskPages'))
expect(authBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(authBlock.indexOf('await loadRestoredView()'))
})
it('relies on the task PATCH for atomic due and recurrence synchronization', () => {
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
expect(saveTaskBlock).not.toContain('/recurrences/')
expect(saveTaskBlock).toContain('as Partial<Task>, false')
const unifiedSaveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges('), app.indexOf('async function removeTask('))
expect(unifiedSaveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
})
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
expect(css).toContain('@media(max-width:390px){.task-compose-sheet')
expect(css).toContain('.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
expect(css).toContain('.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}')
expect(css).toContain('.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}')
expect(css).toContain('.task-compose-sheet .app-sheet__footer>button{min-height:44px}')
expect(css).toContain('.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}')
})
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
vi.useFakeTimers()
const active = new Set<string>()
const pulse = createCompletionPulse((id) => active.add(id), (id) => active.delete(id), 420)
pulse('item-1')
pulse('item-1')
vi.advanceTimersByTime(16)
expect(active.has('item-1')).toBe(true)
vi.advanceTimersByTime(200)
pulse('item-1')
expect(active.has('item-1')).toBe(false)
vi.advanceTimersByTime(16)
expect(active.has('item-1')).toBe(true)
vi.advanceTimersByTime(220)
expect(active.has('item-1')).toBe(true)
vi.advanceTimersByTime(200)
expect(active.has('item-1')).toBe(false)
vi.useRealTimers()
})
})
describe('archived task-list disclosure', () => {
it('keeps one collapsed disclosure row even when the archive is empty', () => {
expect(app).toContain('const archivedListsExpanded = ref(false)')
expect(app).toContain('class="archived-lists-toggle"')
expect(app).toContain('已归档 {{ archivedLists.length }}')
expect(app).toContain(':aria-expanded="archivedLists.length > 0 && archivedListsExpanded"')
expect(app).toContain('aria-controls="archived-task-lists"')
expect(app).toContain(':disabled="archivedLists.length === 0"')
expect(app).toContain('id="archived-task-lists"')
})
it('renders compact archived rows with a restore and purge menu', () => {
expect(app).toContain('v-show="archivedListsExpanded"')
expect(app).toContain('class="list-row archived-row"')
expect(app).toContain('class="archived-row-menu-trigger"')
expect(app).toContain('恢复清单')
expect(app).toContain('永久删除清单')
expect(css).toContain('.archived-lists-toggle{min-height:44px;')
expect(css).toContain('.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;')
expect(css).toContain('.archived-row-actions{position:fixed;z-index:70;')
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
expect(css).toContain('.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;')
})
it('preserves the expanded state while restoring or deleting the last list', () => {
const restoreBlock = app.slice(app.indexOf('async function restoreList'), app.indexOf('function openPurgeList'))
const purgeBlock = app.slice(app.indexOf('async function confirmPurgeList'), app.indexOf('function toggleSidebarCreate'))
expect(restoreBlock).not.toContain('archivedListsExpanded.value = false')
expect(purgeBlock).not.toContain('archivedListsExpanded.value = false')
})
it('keeps archived and ordinary sidebar action menus mutually exclusive', () => {
const archivedActionBlock = app.slice(app.indexOf('function toggleArchivedListAction'), app.indexOf('function openPurgeList'))
const sidebarActionBlock = app.slice(app.indexOf('function openSidebarAction'), app.indexOf('function runSidebarCreate'))
expect(archivedActionBlock).toContain('closeSidebarAction()')
expect(sidebarActionBlock).toContain('closeArchivedListAction(false)')
})
it('closes the desktop archived menu from outside pointer input without replacing the mobile mask', () => {
expect(app).toContain("document.addEventListener('pointerdown', handleArchivedListOutsidePointer)")
expect(app).toContain("window.matchMedia('(min-width: 931px)').matches")
expect(app).toContain("target.closest('.archived-row-menu,.archived-row-actions')) closeArchivedListAction(false)")
expect(app).toContain('<Teleport to="body">')
expect(app).toContain('class="archived-action-mask" @click.self="closeArchivedListAction()"')
expect(app).toContain("document.addEventListener('scroll', handleArchivedListViewportChange, true)")
expect(app).toContain("window.addEventListener('resize', handleArchivedListViewportChange)")
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
})
it('restores archive menu focus after Escape and actions, with a fallback after row removal', () => {
expect(app).toContain('const archivedListsToggle = ref<HTMLButtonElement | null>(null)')
expect(app).toContain('let archivedListActionTrigger: HTMLElement | null = null')
expect(app).toContain("document.addEventListener('keydown', handleArchivedListEscape)")
expect(app).toContain('resolveArchivedMenuFocusTarget(archivedListActionTrigger, archivedListsToggle.value)')
expect(app).toContain('ref="archivedListsToggle"')
expect(app).toContain("@click=\"toggleArchivedListAction(list,$event.currentTarget)\"")
expect(app).toContain('purgeListTrigger = archivedListActionTrigger')
expect(app).toContain('focusArchivedListTrigger()')
})
it('disables archive disclosure motion for reduced-motion users', () => {
expect(css).toContain('@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}')
})
})
describe('mobile sheet contract', () => {
it('uses shared roles for details, creation and secondary actions', () => {
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
expect(css).toContain('.app-sheet__header{min-height:64px;')
expect(css).toContain('.app-sheet__body{min-height:0;overflow-y:auto;')
expect(css).toContain('.app-sheet__footer{position:sticky;bottom:0;')
expect(css).toContain('padding-bottom:calc(16px + env(safe-area-inset-bottom))')
})
it('keeps the danger zone reserved for archived habit deletion', () => {
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
})
})
describe('mobile list row language', () => {
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:#fff;[^}]*border:1px solid var\(--line\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
expect(css).toContain('.countdown-row{grid-template-columns:minmax(0,1fr) 72px;')
expect(css).not.toContain('grid-template-columns:44px minmax(0,1fr) 72px')
expect(css).not.toContain('grid-template-columns:40px minmax(0,1fr) 76px')
expect(css).toContain('.task-main strong,.habit-name,.countdown-main>b{font-size:14px;')
expect(css).toContain('.meta,.countdown-main>small,.countdown-state small{font-size:11px;')
expect(css).toContain('.habit-progress{font-size:16px}')
})
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain('v-if="!selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
})
it('provides an archived-habit viewing path', () => {
expect(mvpPanel).toContain("request('/habits?archived=true')")
expect(mvpPanel).toContain("archiveState === 'success' ? `已归档(${archivedHabits.length}` : '已归档'")
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
expect(mvpPanel).toContain('archiveFlags.list ? archivedHabits : []')
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-section\{[^}]*width:100%/)
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
expect(css).toMatch(/\.archived-habit-row b,\.archived-habit-row small\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
})
it('renders a separate accessible archive section with explicit states and lifecycle actions', () => {
const activeList = mvpPanel.slice(mvpPanel.indexOf('<div v-if="view === \'habits\'" class="habit-list">'), mvpPanel.indexOf('<section v-if="view === \'habits\'" class="habit-archive-section"'))
const todayList = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
expect(activeList).not.toContain('habit-archive-toggle')
expect(todayList).not.toContain('habit-archive-section')
expect(mvpPanel).toContain('class="habit-archive-section"')
expect(mvpPanel).toContain('aria-controls="archived-habits-panel"')
expect(mvpPanel).toContain(':aria-expanded="showArchivedHabits"')
expect(mvpPanel).toContain('id="archived-habits-panel"')
expect(mvpPanel).toContain('archiveState === \'loading\'')
expect(mvpPanel).toContain('archiveState === \'error\'')
expect(mvpPanel).toContain('@click="loadArchivedHabits"')
expect(mvpPanel).toContain('formatArchivedAt(h.archived_at)')
expect(mvpPanel).toContain('<ChevronRight aria-hidden="true"/>')
expect(mvpPanel).toContain('@click="restoreHabit(selectedHabit)"')
expect(mvpPanel).toContain("method: 'POST'")
expect(mvpPanel).toContain('archivedHabits.value = archivedHabits.value.filter((item) => item.id !== h.id)')
expect(mvpPanel).toContain('恢复习惯')
})
it('preserves archived detail after restore or purge failures and falls focus back to disclosure after removal', () => {
const restoreBlock = mvpPanel.slice(mvpPanel.indexOf('async function restoreHabit'), mvpPanel.indexOf('async function deleteHabit'))
const deleteBlock = mvpPanel.slice(mvpPanel.indexOf('async function deleteHabit'), mvpPanel.indexOf('async function loadArchivedHabits'))
expect(restoreBlock).toContain('catch (reason)')
expect(deleteBlock).toContain('catch (reason)')
expect(restoreBlock).toContain('performHabitRestore({')
expect(restoreBlock).toContain('invalidateHabitGridCache()')
expect(restoreBlock).toContain("result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试'")
expect(restoreBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(restoreBlock.indexOf("method: 'POST'"))
expect(deleteBlock.indexOf('selectedHabit.value = null')).toBeGreaterThan(deleteBlock.indexOf("method: 'DELETE'"))
expect(mvpPanel).toContain('habitArchiveToggle.value?.focus()')
expect(mvpPanel).toContain('ref="habitArchiveToggle"')
})
it('styles archive rows as 44px full-width desktop rows and a mobile bottom sheet detail', () => {
expect(css).toMatch(/\.habit-archive-toggle\{[^}]*min-height:44px/)
expect(css).toMatch(/\.archived-habit-row\{[^}]*width:100%;[^}]*min-height:44px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
expect(css).toContain('@media(max-width:930px){.habit-archive-section')
expect(css).toContain('.habit-detail-sheet.app-sheet--detail{')
})
it('reuses the habit composer for edits and patches only changed fields', () => {
expect(mvpPanel).toContain('const editingHabit = ref<Habit | null>(null)')
expect(mvpPanel).toContain('changedHabitFields(originalHabitForm.value, normalized)')
expect(mvpPanel).toContain("method: 'PATCH'")
expect(mvpPanel).toContain("habitComposerTitle")
expect(mvpPanel).toContain("habitFormError.value = reason instanceof Error")
})
})
describe('task and habit row decoration', () => {
it('removes the redundant habit refresh action', () => {
expect(mvpPanel).not.toContain('<RefreshCw />刷新')
expect(mvpPanel).not.toContain('RefreshCw,')
})
it('supports vertical drag handles for reordering visible parent tasks and habits', () => {
expect(app.match(/class="drag-handle task-drag-handle"/g)?.length).toBe(1)
expect(app).toContain('@pointerdown.stop="startTaskReorder')
expect(app).toContain('@pointerup.stop="finishTaskReorder')
expect(app).toContain("api('/tasks/reorder'")
expect(mvpPanel.match(/class="drag-handle habit-drag-handle"/g)?.length).toBe(1)
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
expect(mvpPanel).toContain('@pointerdown.stop="startHabitReorder')
expect(mvpPanel).toContain('@pointerup.stop="finishHabitReorder')
expect(mvpPanel).toContain("request('/habits/reorder'")
expect(css).toContain('.drag-handle{')
expect(css).toContain('.reordering{')
expect(css).toContain('var(--reorder-y,0px)')
expect(app).toContain("'--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px`")
expect(mvpPanel.match(/'--reorder-y': `\$\{habitReorder\?\.id === h\.id \? habitReorder\.offsetY : 0\}px`/g)?.length).toBe(1)
})
it('exposes complete titles on every ellipsized visible task title node', () => {
expect(app.match(/<strong :title="node\.task\.title">\{\{node\.task\.title\}\}<\/strong>/g)).toHaveLength(2)
expect(app).not.toContain('<strong :title="subtask.title">{{subtask.title}}</strong>')
expect(app).not.toContain('<strong>{{node.task.title}}</strong>')
})
it('keeps the Trash restore action at least 44px tall', () => {
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
})
it('reconciles only top-level Trash mutations and distinguishes refresh failure', () => {
const loadBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView'))
const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
const restoreBlock = app.slice(app.indexOf('async function restoreTask'), app.indexOf('async function purgeTask'))
const purgeBlock = app.slice(app.indexOf('async function purgeTask'), app.indexOf('async function addSubtask'))
expect(loadBlock).toContain('return await runLatestRequest')
expect(mutationBlock).toContain('const result = await performTrashMutation(')
expect(mutationBlock).toContain('trash.value = trash.value.filter((item) => item.id !== task.id)')
expect(mutationBlock).toContain('totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)')
expect(mutationBlock).toContain('if (page.value > totalPages.value) page.value = totalPages.value')
expect(mutationBlock).toContain('if (!result.mutated)')
expect(mutationBlock).toContain('fail(result.error)')
expect(mutationBlock).toContain('if (!result.refreshed)')
expect(mutationBlock).toContain('请重试刷新')
expect(restoreBlock).toContain("await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`")
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
})
it('supports pointer dragging for desktop visible task completion', () => {
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(1)
expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(1)
})
it('supports pointer dragging for desktop habit rows in Today and Habits views', () => {
expect(mvpPanel.match(/@pointerdown="startHabitPointer/g)?.length).toBe(2)
expect(mvpPanel.match(/@pointermove="moveHabitPointer/g)?.length).toBe(2)
expect(mvpPanel.match(/@pointerup="finishHabitPointer/g)?.length).toBe(2)
expect(mvpPanel.match(/@pointercancel="cancelHabitPointer/g)?.length).toBe(2)
})
it('updates habit rows locally after swiping instead of refreshing the whole Today section', () => {
expect(mvpPanel).toContain('setLocalHabitValue(h, next)')
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('async function finishHabitSwipe'))
expect(applyBlock).not.toContain('await loadHabits()')
expect(applyBlock).toContain('setLocalHabitValue(h, next)')
expect(applyBlock).toContain('setLocalHabitValue(h, previous)')
})
it('keeps swipe behavior without rendering the swipe background layer', () => {
expect(app).not.toContain('swipe-bg')
expect(mvpPanel).not.toContain('swipe-bg')
expect(css).not.toContain('.swipe-bg')
expect(app).toContain('shouldToggleRowSwipe')
expect(mvpPanel).toContain('shouldToggleRowSwipe')
})
it('shows subtasks only in task details while keeping the parent progress summary', () => {
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<h3 id="today-tasks"'))
const ordinaryRows = app.slice(app.indexOf('<section class="task-list"'), app.indexOf('</section>', app.indexOf('<section class="task-list"')))
expect(overdue).not.toContain('v-for="subtask in node.subtasks"')
expect(ordinaryRows).not.toContain('v-for="subtask in node.subtasks"')
expect(app).not.toContain('collapsedTaskIds')
expect(app).not.toContain('toggleTaskChildren')
expect(app.match(/<span v-if="node\.subtasks\.length" class="meta">/g)).toHaveLength(2)
expect(app).toContain('v-for="subtask in selectedTaskSubtasks"')
expect(app).toContain('class="subtask-detail"')
})
it('shows visible completion buttons for parent tasks while keeping subtask completion in details', () => {
expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed')
expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed')
expect(app).toContain('class="task-check"')
expect(app.match(/class="task-check"/g)?.length).toBe(2)
expect(app).toContain('class="task-check-mark"')
expect(app).toContain('class="subtask-detail" @click="toggle(subtask)"')
expect(css).toContain('.task-check{')
expect(css).toContain('.task-check-mark{')
expect(css).toContain('.task-check:focus-visible{')
})
it('reconciles concurrent completion mutations without suppressing their feedback', () => {
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('const completing = !task.completed')
expect(toggleBlock).toContain('await taskMutationReconciler.run(')
expect(toggleBlock).toContain("() => toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated)')
expect(toggleBlock).not.toContain("beginLatestRequest('tasks')")
})
it('shows the same visible round check control for boolean and numeric habits in both views', () => {
expect(mvpPanel).not.toContain('class="sr-only" :aria-label="isDone(h, todayKey)')
expect(mvpPanel.match(/class="task-check habit-check"/g)?.length).toBe(2)
expect(mvpPanel.match(/class="task-check-mark"/g)?.length).toBe(2)
expect(mvpPanel.match(/<Check v-if="isDone\(h, todayKey\)"/g)?.length).toBe(2)
expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer')
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('function currentHabitForm'))
expect(toggleBlock).toContain('setLocalHabitValue(h, next)')
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
expect(toggleBlock).not.toContain('await loadHabits()')
})
it('places numeric habit progress at the far right instead of under the title', () => {
expect(mvpPanel.match(/<small v-if="habitProgressText\(h\)" class="habit-progress">/g)?.length).toBe(2)
expect(mvpPanel).not.toContain('<span><span class="habit-name">{{ h.name }}</span><small')
expect(css).toContain('.habit-progress{margin-left:auto;')
})
it('keeps Today focused and adds the weekly grid only to the full habits view', () => {
expect(mvpPanel.match(/class="habit-week"/g)?.length).toBe(1)
expect(mvpPanel).toContain('v-for="cell in h.cells"')
expect(mvpPanel).toContain('class="habit-week-cell"')
expect(mvpPanel).toContain('class="habit-week-day"')
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?class="habit-week"[\s\S]*?<!-- 完整习惯列表 -->/)
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
})
it('uses numeric progress as the main feedback and omits it for boolean habits', () => {
expect(mvpPanel.match(/<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2)
expect(mvpPanel.match(/:value="habitProgressValue\(h\)" :max="habitProgressMax\(h\)"/g)?.length).toBe(2)
expect(mvpPanel.match(/:aria-label="`\$\{h.name\}进度:\$\{habitProgressText\(h\)\}`"/g)?.length).toBe(2)
expect(css).toContain('.habit-progress{margin-left:auto;')
expect(css).toContain('font-size:16px')
expect(css).toContain('.habit-progress-bar{grid-column:1/-1;')
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)')
})
it('restores the current page and list from local storage', () => {
expect(app).toContain("readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)")
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")
expect(app).toContain('if (restoredNavigation.view === \'tasks\' && restoredNavigation.listId)')
})
it('uses one topbar pill and keeps App as the only preference owner', () => {
expect(app).toContain("import CompletedFilterPill from './components/CompletedFilterPill.vue'")
expect(app.match(/<CompletedFilterPill/g)).toHaveLength(1)
expect(app).toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\"")
expect(app).toContain('v-model="showCompleted"')
expect(app).toContain('class="topbar-actions"')
expect(app).toContain('topbar-refresh')
expect(app).toContain('aria-label="刷新当前页面"')
expect(app).toContain('@click="refreshCurrentView"')
expect(app).toContain(':class="{spinning:refreshing}"')
expect(app).toContain(':disabled="refreshing || loading"')
expect(app).toContain("else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])")
expect(app).toContain("if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()")
expect(mvpPanel).toContain('defineExpose({ openHabitComposer, refreshHabits: loadHabits })')
expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
expect(app).toContain(':show-completed="showCompleted"')
expect(app).not.toContain('@update:show-completed')
expect(mvpPanel).toContain('showCompleted: boolean')
expect(mvpPanel).not.toContain("'update:showCompleted': [value: boolean]")
expect(completedFilterPill).not.toContain('localStorage')
})
it('removes duplicate completed filters while preserving habit completion buttons', () => {
expect(app).not.toContain('today-completed-toolbar')
expect(app).not.toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
expect(mvpPanel).not.toContain('class="habit-toolbar"')
expect(mvpPanel).not.toMatch(/type="checkbox"[^>]*显示已完成/)
expect(mvpPanel.match(/class="task-check habit-check"/g)?.length).toBe(2)
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
expect(todayHabits).not.toContain('completed-filter-pill')
expect(todayHabits).not.toContain('显示已完成')
})
it('hides the desktop task-list scrollbar without disabling main scrolling', () => {
const desktopMain = css.slice(css.indexOf('main{container-type:inline-size;'), css.indexOf('.topbar{'))
expect(desktopMain).toContain('overflow:auto')
expect(css).toContain('@media(min-width:931px){main{scrollbar-width:none}main::-webkit-scrollbar{display:none}}')
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}')
expect(css).not.toContain('@media(max-width:930px){main{scrollbar-width:none}')
})
it('uses a full-width search row and hides it behind mobile pull-to-reveal', () => {
expect(css).toContain('main{container-type:inline-size;')
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" "search search search";')
expect(css).toContain('.search-reveal{grid-area:search;width:100%;min-width:0}')
expect(css).toContain('.topbar .search{width:100%;margin:0}')
expect(css).toContain('.topbar-title{grid-area:title;min-width:0;width:100%}')
expect(css).toContain('.topbar h1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;')
expect(css).toContain('.topbar-filter{justify-self:end;')
expect(css).toContain('.topbar-actions{grid-area:filter;justify-self:end;display:flex;align-items:center;gap:8px}')
expect(css).toContain('.topbar-refresh{width:44px;height:44px;')
expect(css).toContain('.topbar-refresh.spinning svg{animation:topbar-refresh-spin .7s linear infinite}')
expect(app.indexOf('class="topbar-actions"')).toBeLessThan(app.indexOf('class="search-reveal"'))
expect(app).toContain('@touchstart="startSearchPull"')
expect(app).toContain('@touchmove="moveSearchPull"')
expect(app).toContain('@touchend="finishSearchPull"')
expect(app).toContain('ref="searchInput"')
expect(app).toContain('class="search-pull-hint"')
expect(css).toContain('.search-reveal{max-height:0;opacity:0;overflow:hidden;visibility:hidden;pointer-events:none;')
expect(css).toContain('.search-reveal.mobile-search-open{max-height:52px;opacity:1;visibility:visible;pointer-events:auto;transform:none;overflow:visible}')
expect(css).toContain('.search-reveal.mobile-search-pulling{max-height:var(--search-pull);')
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
expect(css).not.toContain('width:min(260px,100%)')
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}')
expect(css).not.toContain('.habit-toolbar{')
})
it('filters habits with the shared preference and disables sorting while completed items are hidden', () => {
expect(mvpPanel).toContain('const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value')
expect(mvpPanel).toContain('const visibleHabits = computed(() => props.showCompleted ? habits.value')
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
expect(mvpPanel).toContain(':disabled="!showCompleted"')
expect(mvpPanel).toContain('if (busy.value || !props.showCompleted) return')
expect(css).not.toContain('.habit-toolbar{')
})
it('distinguishes hidden completed items from true and search empty states', () => {
expect(app).toContain('const hiddenCompletedTaskCount = ref(0)')
expect(app).toContain("query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
expect(mvpPanel).toContain("!showCompleted && todayHabits.length ? '已完成的习惯已隐藏。'")
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
})
it('reclassifies Today tasks after due edits without toggling page loading', () => {
const saveBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function removeTask'))
const refreshBlock = app.slice(app.indexOf('async function refreshTodayAfterTaskSave()'), app.indexOf('async function saveTask('))
expect(saveBlock).toContain('await refreshTodayAfterTaskSave()')
expect(refreshBlock).toContain('loadTasksPage(request)')
expect(refreshBlock).toContain('loadOverdueTasks(request)')
expect(refreshBlock).toContain('loadTodayTaskSummary()')
expect(refreshBlock).not.toContain('loading.value = true')
})
it('shows unfinished overdue tasks as a separate list inside Today', () => {
expect(app).toContain('const overdueTasks = ref<Task[]>([])')
expect(app).toContain("params.set('due_to', isoAtLocalDayOffset(0))")
expect(app).toContain("params.set('completed', 'false')")
expect(app).toContain('class="overdue-section"')
expect(app).toContain('已过期')
expect(app).toContain('v-for="node in overdueTaskTree"')
expect(css).toContain('.overdue-section{')
expect(css).toContain('.overdue-heading{')
})
it('keeps Today as a low-noise dashboard with direct empty-state actions', () => {
expect(app).toContain('class="today-board"')
expect(app).toContain('class="today-track today-task-track"')
expect(app).toContain('class="today-track today-habit-track"')
expect(app).toContain(':style="{ width: todayTaskProgressPercent }"')
expect(app).toContain(':style="{ width: todayHabitProgressPercent }"')
expect(app).toContain('async function loadTodayTaskSummary()')
expect(app).toContain('const token = ++todaySummaryLoadToken')
expect(app).toContain("params.set('completed', String(completed))")
expect(app).toContain('const overdueTotal = async () => {')
expect(app).toContain('await Promise.all([summaryTotal(false), summaryTotal(true), overdueTotal()])')
expect(app).toContain('todayTaskTotal.value = overdue + open + completed')
expect(app).toContain("if (activeView.value === 'today') void loadTodayTaskSummary()")
expect(app).toContain('aria-controls="today-tasks"')
expect(app).toContain('aria-controls="today-habits"')
expect(app).toContain('role="progressbar"')
expect(app).toContain(':aria-valuemax="todayTaskTotal"')
expect(app).toContain(':aria-valuenow="todayHabitCompleted"')
expect(app).toContain('任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}')
expect(app).toContain('习惯 {{ todayHabitProgress }}')
expect(app).not.toContain('const todayProgressText = computed')
expect(app).not.toContain('const todayTaskProgressHint = computed')
expect(app).not.toContain('const todayHabitProgressHint = computed')
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
expect(app).not.toContain('class="paper today-summary"')
expect(app).not.toContain('today-summary-grid')
expect(app).toContain("scrollTodaySection('today-tasks')")
expect(app).toContain("scrollTodaySection('today-habits')")
expect(app).not.toContain('<div class="today-stat"')
expect(app).toContain('@summary="updateTodayHabitSummary"')
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
expect(mvpPanel).toContain('const todayHabitSummary = computed')
expect(mvpPanel).toContain("emit('summary', value)")
expect(app).toContain('taskComposeTitle')
expect(app).toContain('添加今天任务')
expect(app).toContain('v-if="activeView===\'trash\' || totalPages > 1 || totalTasks > 0"')
expect(mvpPanel).toContain('class="empty-panel today-empty-panel"')
expect(mvpPanel).toContain('添加习惯')
expect(css).toContain('.today-board{')
expect(css).toContain('.today-track{')
expect(css).toContain('.today-track-fill{')
expect(css).toContain('.today-habit-track .today-track-fill{')
expect(css).not.toContain('.today-stat{')
expect(css).not.toContain('.today-summary{')
expect(css).not.toContain('.today-summary-grid{')
expect(css).not.toContain('.today-track-head small{')
expect(css).toContain('.empty-action{')
})
it('keeps cached today habits visible during background refresh', () => {
expect(mvpPanel).toContain('readHabitGridCache<Habit>(week)')
expect(mvpPanel).toContain('writeHabitGridCache(week, habits.value)')
expect(mvpPanel).toContain("view === 'today-habits' && (habits.length || !busy)")
expect(mvpPanel).not.toContain("view === 'today-habits' && busy\" class=\"empty-panel\">加载中…")
})
it('shows a password form with confirmation and calls the protected endpoint', () => {
expect(mvpPanel).toContain('class="password-form"')
expect(mvpPanel).toContain('aria-label="当前密码"')
expect(mvpPanel).toContain('aria-label="新密码"')
expect(mvpPanel).toContain('aria-label="确认新密码"')
expect(mvpPanel).toContain("request('/auth/change-password'")
expect(mvpPanel).toContain('两次输入的新密码不一致')
expect(css).toContain('.password-form{width:100%;display:grid;gap:10px}')
})
it('uses a gentle completion treatment instead of a discarded-task strike', () => {
expect(css).toContain('.habit-row.done .habit-name{color:#756c61;')
expect(css).toContain('text-decoration-color:#d8c8bb')
expect(css).toContain('text-decoration-thickness:1px')
expect(css).not.toContain('.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through')
})
it('keeps habit cards borderless so the rounded left edge has no visual gap', () => {
expect(css).toMatch(/\.habit-row\{border:0;/)
expect(css).not.toMatch(/\.habit-row\{[^}]*border-top:/)
expect(css).not.toMatch(/\.habit-row\{[^}]*border-right:/)
expect(css).not.toMatch(/\.habit-row\{[^}]*border-bottom:/)
})
it('keeps task rows borderless on the left and habit rows fully borderless', () => {
expect(css).toContain('.task-row{border-left:0;')
expect(css).toContain('.habit-row{border:0;')
})
})
describe('mobile touch targets', () => {
it('keeps primary mobile controls at least 44px high', () => {
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toContain('.mini-icon,.row-actions button{min-width:44px;min-height:44px;')
expect(css).toContain('.bottom button{min-height:44px;')
expect(css).toContain('.unified-fab{display:grid;place-items:center;')
expect(css).toContain('.countdown-modal header button{min-width:44px;min-height:44px;')
})
it('keeps mobile-only menu buttons hidden on desktop despite the .icon grid rule', () => {
expect(css).toMatch(/\.icon,\.ghost\{[^}]*display:grid[^}]*\}\.mobile-only\{display:none\}/)
expect(css).toContain('@media(max-width:930px){.shell')
})
})
describe('approved habit safety and U2 title hierarchy', () => {
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain('<h2>修改密码</h2>')
expect(mvpPanel).toContain('<h2>登录会话</h2>')
expect(mvpPanel).toContain('<h2>最近活动</h2>')
expect(css).toContain('.tool-card>h2{')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const modalError = ref')
expect(app).toContain('role="alert" class="field-error"')
expect(app).toContain('normalizeRequiredName')
expect(mvpPanel).toContain('habitErrors.name')
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
expect(mvpPanel).toContain('const habitFormInvalid = computed')
expect(mvpPanel).toContain(':disabled="busy || habitFormInvalid"')
expect(mvpPanel).toContain('请修正表单中的错误后再保存')
})
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
expect(css).toContain('.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
})
it('disables illegal habit writes and keeps optimistic rollback', () => {
expect(mvpPanel).toContain('habitAction(h)')
expect(mvpPanel).toContain(':disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain(':aria-disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain('setLocalHabitValue(h, previous)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})
describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV')
expect(mvpPanel).not.toContain('导出 JSON')
expect(mvpPanel).toContain('@click="restore"')
expect(mvpPanel).not.toContain('<h3>导入</h3>')
expect(mvpPanel).not.toContain("request('/import/ticktick")
expect(mvpPanel).not.toContain('importFile')
expect(mvpPanel).not.toContain('importPreview')
})
})
describe('task detail layout', () => {
it('keeps long subtask text inside the detail panel', () => {
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);')
expect(css).toContain('.detail-form>*{min-width:0}')
expect(css).toContain('.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;')
})
it('keeps the list, due datetime and repeat controls equally compact', () => {
expect(app).toContain('<label class="task-detail-due-row">截止时间')
expect(app).toContain('class="task-detail-due-input task-detail-field-input"')
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
})
})
describe('unified floating add interaction', () => {
it('uses compact date and time chips instead of large visible mobile inputs', () => {
expect(app).toContain("composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''")
expect(app).toContain('const composeDueLabel = computed')
expect(app).toContain('ref="composeDateButton"')
expect(app).toContain('aria-haspopup="dialog" :aria-expanded="composeCalendarOpen"')
expect(app).toContain('class="task-compose-date-chip"')
expect(app).toContain('@click="composeCalendarOpen=true"')
expect(app).toContain('<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt"')
expect(app).toContain('<span>{{ composeDueLabel }}</span>')
expect(app).toContain('v-if="composeDueAt" class="task-compose-date-clear"')
expect(app).toContain('v-if="composeDueAt && !composeHasTime"')
expect(app).toContain('v-else-if="composeDueAt" class="task-compose-time-chip"')
expect(app).toContain('function clearComposeDueDate()')
expect(css).toContain('.task-compose-date-control{position:relative;display:flex;align-items:center;min-width:0}')
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important')
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important;grid-template-columns:none!important;align-items:center;flex-direction:row!important;gap:7px!important;width:auto;min-height:44px')
expect(css).not.toContain('@media(max-width:480px){.task-compose-due-row')
expect(css).not.toContain('.task-compose-native-picker{')
expect(app).toContain("composeHasTime.value ? composeTime.value : '23:59'")
})
it('keeps the current task list visible after creating a task', () => {
const submitTaskCompose = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(submitTaskCompose).not.toContain('selectTask(task)')
expect(submitTaskCompose).toContain("toast('任务已添加')")
})
it('lets task create and detail forms configure a repeat rule', () => {
expect(app).toContain('const composeRepeat = ref')
expect(app).toContain('const selectedTaskRepeat = ref')
expect(app).toContain('重复<select v-model="composeRepeat"')
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(createBlock).toContain("api('/tasks',")
expect(createBlock).not.toContain("api('/recurrences'")
const saveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges()'), app.indexOf('async function removeTask'))
expect(saveBlock).toContain("const taskId = selectedTask.value?.id")
expect(saveBlock).toContain('const selectionToken = recurrenceLoadToken')
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
expect(saveBlock).toContain('structuredClone(selectedRepeatConfig.value)')
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
expect(saveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)')
expect(saveBlock).toContain("selectedRepeatError.value = ''")
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'")
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)'))
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
expect(saveBlock).toContain('savingSelectedTask.value = true')
expect(saveBlock).toContain('savingSelectedTask.value = false')
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
expect(app).toContain(':disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges"')
expect(app).not.toContain('@blur="saveTask()"')
expect(app).not.toContain('@change="saveTask()"')
expect(app).not.toContain('value;saveTask()')
expect(app).not.toContain('async function updateSelectedTaskRepeat()')
expect(app).not.toContain('保存重复设置')
expect(css).toContain('.after-completion-fields input{width:76px;min-height:44px')
expect(app).toContain('<option value="custom">自定义…</option>')
expect(app).toContain('class="repeat-custom-fields"')
expect(app).toContain('每隔')
expect(app).toContain('重复日期')
expect(app).toContain('结束方式')
expect(app).toContain('重复次数')
})
it('preloads countdowns through the shared cache after authentication', () => {
expect(app).toContain('void preloadCountdowns()')
expect(app).toContain('await loadCountdownCache(async () =>')
expect(app).toContain("Promise.all([api('/countdowns'), api('/countdowns?archived=true')])")
})
it('reuses one draggable FAB component for task, habit, and countdown views', () => {
expect(app).toContain("import FloatingAddButton from './components/FloatingAddButton.vue'")
expect(app).toContain('<FloatingAddButton')
expect(floatingAdd).toContain('class="unified-fab"')
expect(floatingAdd).toContain('@pointerdown="startDrag"')
expect(floatingAdd).toContain('@pointermove="moveDrag"')
expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging')
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
expect(css).toContain('.countdown-compose-enter-active')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
})
it('keeps the shared FAB visually simple', () => {
expect(floatingAdd).not.toContain('unified-fab-aura')
expect(floatingAdd).not.toContain('unified-fab-core')
expect(css).not.toContain('.unified-fab:before')
expect(css).not.toContain('@keyframes fab-aura')
expect(css).not.toContain('@keyframes fab-pulse')
expect(css).toMatch(/\.unified-fab\{[^}]*background:var\(--accent\)/)
})
it('keeps the countdown close button but removes its orange circular background', () => {
expect(countdownPanel).toContain('<button type="button" aria-label="关闭" @click="closeDialog"><X/></button>')
expect(css).toMatch(/\.countdown-modal header button\{[^}]*background:transparent/)
expect(css).toMatch(/\.countdown-modal header button\{[^}]*border-radius:8px/)
})
it('removes inline create forms and opens the correct composer from the FAB', () => {
expect(app).not.toContain('class="quick"')
expect(mvpPanel).not.toContain('class="habit-create"')
expect(countdownPanel).not.toContain('class="countdown-add"')
expect(countdownPanel).not.toContain('class="fab countdown-fab')
expect(countdownPanel).not.toContain('@click="openCountdownComposer"')
expect(app).toContain("activeView==='habits'")
expect(app).toContain("activeView==='countdowns'")
expect(mvpPanel).toContain('openHabitComposer')
expect(countdownPanel).toContain('openCountdownComposer')
})
})
describe('desktop task detail disclosure', () => {
it('gives the task list the full remaining width until a task is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask)")
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
expect(app).toContain('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()')
expect(app).not.toContain('<div v-else class="paper">')
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 0;')
expect(css).toContain('.shell.detail-open{grid-template-columns:238px minmax(430px,1fr) 350px}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
expect(css).toContain('transition:grid-template-columns .22s ease')
})
})
describe('desktop sidebar collapse & folder scrollbar', () => {
it('keeps overlay scrims out of the desktop grid layout', () => {
expect(css).toContain('.scrim{display:none}')
expect(css).toContain('.scrim{position:fixed;z-index:35;')
})
it('lets the top menu collapse the desktop sidebar', () => {
expect(app).toContain('sidebar-collapsed')
expect(app).toContain('toggleSidebar')
expect(css).toContain('.shell.sidebar-collapsed{grid-template-columns:0 minmax(430px,1fr) 0}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
})
it('hides the folders scrollbar while staying scrollable', () => {
expect(css).toContain('scrollbar-width:none')
expect(css).toContain('.folders::-webkit-scrollbar{display:none}')
})
})
describe('sidebar information hierarchy', () => {
it('keeps daily navigation above lists and low-frequency management at the bottom', () => {
const primary = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(primary).toContain('收集箱')
expect(primary).toContain('今天')
expect(primary).toContain('最近 7 天')
expect(primary).toContain('习惯')
expect(primary).toContain('倒数日')
expect(primary).not.toContain('回收站')
expect(app).toContain('class="sidebar-management"')
expect(app).toMatch(/sidebar-management[\s\S]*?switchView\('trash'\)[\s\S]*?回收站[\s\S]*?switchView\('settings'\)[\s\S]*?设置/)
})
it('uses a compact brand row, restrained active marker and a single list creation trigger', () => {
expect(app).toContain('class="mini-icon list-create-trigger" aria-label="新建清单或文件夹"')
expect(app).not.toContain('aria-label="新建文件夹" @click="createFolder"')
expect(css).toContain('.brand-row{height:64px;')
expect(app).toContain('class="brand brand-lockup"')
expect(app).toContain('class="brand small brand-lockup"')
expect(app).toContain('class="center loading-brand"')
expect(app).toContain('class="loading-logo"')
expect(app.match(/src="\/dodo-logo\.svg"/g)).toHaveLength(3)
expect(css).toContain('.loading-brand{grid-template-columns:auto;justify-items:center;gap:13px}')
expect(css).toContain('.loading-logo{width:40px;height:40px;')
expect(css).toContain('.brand-logo{width:42px;height:42px;')
expect(css).toContain('.brand.small .brand-logo{width:32px;height:32px}')
expect(css).not.toContain('.brand span{color:var(--accent)}')
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
expect(css).toContain('width:3px;')
})
it('collapses list management actions behind one explicit more button', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity')
expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity')
})
})
describe('sidebar layout', () => {
it('switches to the compact layout before the desktop grid can be clipped', () => {
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).not.toContain('@media(max-width:800px){.shell')
})
it('lets the list area flex and scroll inside the fixed sidebar', () => {
expect(css).toContain('.folders{flex:1;min-height:0;')
expect(css).toContain('overflow-y:auto')
})
it('keeps the mobile sidebar above the scrim so it remains clickable and scrollable', () => {
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
expect(css).toContain('.scrim{position:fixed;z-index:35;')
})
it('locks background scrolling while the mobile sidebar is open', () => {
expect(app).toContain("'mobile-sidebar-open': mobileSidebar")
expect(css).toContain('.shell.mobile-sidebar-open{overflow:hidden}')
expect(css).toContain('.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}')
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
expect(css).toContain('overscroll-behavior:contain')
})
it('uses valid sibling controls for sidebar rows', () => {
expect(app).toContain('class="list-row"')
expect(app).toContain('class="list-row-main draggable-list-row-main"')
expect(app).not.toMatch(/<button[^>]*class="list-row"/)
})
it('reveals row actions for pointer and keyboard focus without reserving desktop width', () => {
expect(css).toContain('.folder-row:hover .row-actions,.folder-row:focus-within .row-actions,.list-row:hover .row-actions,.list-row:focus-within .row-actions')
expect(css).toContain('.row-actions{display:flex;position:absolute;right:0;opacity:0;pointer-events:none;')
})
it('exposes full list and folder names to assistive labels and native tooltips', () => {
expect(app).toContain(':title="folder.name" :aria-label="folder.name"')
expect(app).toContain(':title="list.name" :aria-label="list.name"')
})
it('supports dragging movable lists into folders, out to My Lists, and within one scope', () => {
expect(app).toContain('class="section-title list-root-drop"')
expect(app).toContain(':data-folder-id="folder.id"')
expect(app).toContain(':data-list-id="list.id"')
expect(app).toContain('class="list-drag-handle"')
expect(app).toContain("api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })")
expect(app).toContain("} else {\n await api('/lists/reorder'")
expect(app).toContain('lists.value = previous')
expect(app).toContain('expandedFolders.value = new Set(expandedFolders.value).add(folderId)')
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
})
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
expect(app).not.toContain('function startListLongPress')
expect(app).not.toContain('listLongPressTimer')
expect(app).toContain('@pointerdown.stop="startListHandlePress(list,$event)"')
expect(app).toContain('listHandlePending = { id: list.id, pointer }')
expect(app).toContain('window.setTimeout(() => beginListDrag(list, pointer), 450)')
expect(app).toContain('@pointermove.stop="moveListHandle(list,$event)"')
expect(app).toContain("if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId)")
expect(app).toContain('hasExceededLongPressMovement(')
expect(app).toContain('clearListHandlePress()')
expect(app).toContain('moveListDrag(list, event)')
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
expect(app).toContain('listHandlePending = undefined')
expect(app).toContain('aria-label="上移清单"')
expect(app).toContain('aria-label="下移清单"')
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
expect(app).toContain('aria-label="移动到文件夹"')
expect(app).toContain('role="menu"')
expect(app).toContain('role="menuitem"')
expect(app).toContain('移出文件夹')
})
it('shows drag lift, folder highlighting, and insertion targets', () => {
expect(css).toContain('.list-row.list-dragging{')
expect(css).toContain('.folder-row.list-drop-target{')
expect(css).toContain('.list-root-drop.list-drop-target{')
expect(css).toContain('.list-row.list-reorder-target{')
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
expect(css).toContain('.list-row{touch-action:pan-y}')
expect(css).not.toContain('.list-drag-handle{display:none}')
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
})
it('removes the extra left inset only from draggable list row main controls', () => {
expect(app.match(/class="list-row-main draggable-list-row-main"/g)).toHaveLength(2)
expect(app).toContain('class="list-row archived-row"')
expect(app).not.toContain('class="list-row-main draggable-list-row-main archived-row"')
expect(css).toContain('.draggable-list-row-main{padding-left:0}')
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
})
it('only restores archived lists from the explicit restore menu action', () => {
expect(app).toContain('class="archived-row-label"')
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
expect(app).toContain('@click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单')
})
it('loads archived lists during bootstrap so archived rows are visible after refresh', () => {
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
expect(bootstrapBlock).toContain('await loadArchivedLists()')
expect(bootstrapBlock.indexOf('await loadArchivedLists()')).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
})
it('styles archived rows through the compact disclosure structure', () => {
expect(css).toContain('.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;')
})
it('offers permanent deletion only inside archived list menus', () => {
expect(app).toContain('@click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单')
expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })")
expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/)
})
it('uses a guarded custom confirmation that keeps failures visible', () => {
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
expect(app).toContain('ref="purgeCancelButton"')
expect(app).toContain('purgeCancelButton.value?.focus()')
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
expect(app).toContain('if (purgeListSubmitting.value) return')
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
expect(app).toContain(':disabled="purgeListSubmitting"')
expect(app).toContain('role="alert" class="purge-list-error"')
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
})
it('removes a purged row and persists inbox navigation after archiving the current list', () => {
expect(app).toContain('archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)')
expect(app).toContain("const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id")
expect(app).toContain('if (wasCurrentList)')
expect(app).toContain("await switchView('tasks', inboxId)")
expect(app).toContain('selectedTask.value = null')
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")
})
})