Files
dodo/frontend/src/style.test.ts
T
bboysoul af83a68fe1
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 4m41s
feat: add calendar frontend
2026-09-20 13:21:47 +08:00

1802 lines
117 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')
const appSheet = readFileSync('src/components/AppSheet.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).not.toContain('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 single-line 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('.plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}')
expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}')
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('overrides the legacy timed mobile presentation inside every plain list', () => {
expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}')
expect(css).toContain('.plain-list .task-due--timed .task-due__text{display:block;')
expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}')
})
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('approved cream solid button system', () => {
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;border-radius:10px;font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:16px;gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid #ddd0c0;background:#fffaf3;color:#51483f;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(83,62,42,.06)}')
expect(css).toContain('.danger-button{border:1px solid #e2b7ac;background:#fff8f5;color:#b83c2c;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(105,48,34,.05)}')
})
it('provides restrained interaction, focus, and disabled states without resizing buttons', () => {
expect(css).toContain(':is(.primary,.primary-small):hover:not(:disabled){background:#dd4b1e;border-color:#dd4b1e}')
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button):hover:not(:disabled):not(.disabled){background:#f8eddf;border-color:#d5c5b2}')
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:translateY(1px) scale(.985)}')
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):focus-visible{outline:3px solid rgba(241,90,41,.24);outline-offset:3px}')
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):disabled,.file-button.disabled{background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;cursor:not-allowed;opacity:1}')
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button:disabled,.backup-preflight .danger-button:disabled,.password-form .primary-small:disabled){background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;opacity:1}')
})
it('uses a real disabled file-picker button and keeps its input out of layout', () => {
expect(mvpPanel).toContain('function openRestoreFilePicker()')
expect(mvpPanel).toContain('<button type="button" class="file-button" :disabled="backupBusy" @click="openRestoreFilePicker"><ArchiveRestore />选择文件</button>')
expect(mvpPanel).toContain('<input ref="restoreInput" class="restore-file-input" type="file" tabindex="-1" aria-hidden="true"')
expect(css).toContain('.restore-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}')
expect(mvpPanel).not.toContain('<label class="file-button"')
expect(css).not.toContain('.task-compose-sheet button:disabled{opacity:.45')
expect(css).not.toContain('.pager button:disabled{opacity:.4')
expect(css).not.toContain('.purge-list-dialog button:disabled{opacity:.62')
})
it('keeps special-purpose controls outside the semantic action material', () => {
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;')
expect(css).toContain('width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent)')
expect(css).toContain('.calendar-picker__header button{width:44px;height:44px}')
expect(css).toContain('.calendar-picker__grid button{min-height:44px}')
expect(css).not.toContain('button:not(')
})
})
describe('mobile navigation styles', () => {
it('uses five direct mobile destinations and keeps Settings in the sidebar', () => {
expect(app).not.toContain('aria-controls="mobile-more-menu"')
expect(app).not.toContain('<Ellipsis/><span>更多</span>')
expect(app).toContain("<StickyNote/><span>备忘录</span>")
expect(app).toContain("<CalendarDays/><span>日历</span>")
expect(app).not.toContain("@click=\"switchView('settings')\"><Settings/><span>设置</span>")
})
it('marks only exact mobile destinations active and exposes aria-current only there', () => {
for (const view of ['today', 'habits', 'countdowns', 'memos', 'calendar']) {
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('has no unreachable mobile More state, template, or styles', () => {
expect(app).not.toContain('mobileMore')
expect(app).not.toContain('mobile-more-menu')
expect(app).not.toContain('more-mask')
expect(css).not.toContain('.more-mask')
expect(css).not.toContain('.more-sheet')
expect(css).not.toContain('.task-compose-mask')
expect(css).not.toContain('.habit-detail-mask')
expect(css).not.toContain('.countdown-detail-mask')
expect(css).not.toContain('.countdown-modal-mask')
expect(css).not.toContain('.modal-mask')
expect(css).not.toContain('.modal-box')
expect(css).not.toContain('.countdown-compose-enter')
})
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('approved Settings 01 paper ledger', () => {
it('moves Settings identity into the body and suppresses the duplicate shell title', () => {
expect(app).not.toContain("'settings-main':activeView==='settings'")
expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"")
expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"")
expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"')
expect(app).not.toContain('aria-label="刷新当前页面"')
expect(mvpPanel).toContain('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
expect(mvpPanel).toContain('<h2>数据与恢复</h2><small>完整备份</small>')
expect(mvpPanel).toContain("<h2>登录设备</h2><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small>")
expect(mvpPanel).toContain('<h2>活动记录</h2><small>最近操作</small>')
})
it('uses the selected continuous-paper geometry on desktop and mobile', () => {
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:#fffdf8}')
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}')
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}')
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;')
expect(css).toContain('.settings-topbar{margin-bottom:0}')
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}')
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
expect(css).toContain('.settings-row{min-height:64px}')
})
it('keeps controls and password errors aligned with the approved form contract', () => {
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){border-radius:10px}')
expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}')
expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}')
expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}')
expect(mvpPanel).toContain("const sessionsState = ref<'loading' | 'success' | 'error'>('loading')")
expect(mvpPanel).toContain("const auditState = ref<'loading' | 'success' | 'error'>('loading')")
expect(mvpPanel).toContain('await Promise.allSettled([')
expect(mvpPanel).toContain("v-if=\"sessionsState==='loading'\"")
expect(mvpPanel).toContain("v-else-if=\"sessionsState==='error'\"")
expect(mvpPanel).toContain("v-if=\"auditState==='loading'\"")
expect(mvpPanel).toContain("v-else-if=\"auditState==='error'\"")
expect(mvpPanel).not.toContain("'/sessions').catch(() => [])")
expect(css).toContain('@media(max-width:720px){')
expect(css).toContain('.password-form.settings-form{grid-template-columns:1fr}')
})
})
describe('settings sessions and audit activity', () => {
it('offers a current-device logout action that returns the app to login', () => {
expect(mvpPanel).toContain("import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'")
expect(mvpPanel).toContain("logout: []")
expect(mvpPanel).toContain("await request('/auth/logout', { method: 'POST' })")
expect(mvpPanel).toContain("emit('logout')")
expect(mvpPanel).toContain('class="settings-row settings-logout-row"')
expect(mvpPanel).toContain('<b>退出登录</b><small>仅退出当前设备,不影响其他设备</small>')
expect(mvpPanel).toContain('class="danger-text settings-logout-button"')
expect(mvpPanel).toContain("{{ logoutBusy ? '正在退出…' : '退出登录' }}")
expect(app).toContain('@logout="completeLogout"')
expect(app).toContain('function completeLogout()')
expect(css).toContain('.settings-logout-button{min-width:88px;display:inline-flex;align-items:center;justify-content:center;gap:6px}')
})
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("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
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:#f7efe3')
expect(css).toContain('main{background:var(--surface-base)}')
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
expect(css).toContain('.app-sheet,.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('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
})
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{background:var(--surface-raised)')
expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)')
expect(css).toContain('@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));')
expect(css).toContain('border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom)')
expect(css).toMatch(/\.bottom button\{[^}]*min-height:44px/)
expect(css).toMatch(/\.bottom button\.active\{[^}]*background:transparent/)
expect(css).toContain('.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;')
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
})
})
describe('approved UI detail direction', () => {
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
expect(ordinaryRows).not.toContain('task-detail-trigger')
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('id="today-tasks-heading"'))
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
expect(overdue).not.toContain('aria-label="打开任务详情"')
})
it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">')
expect(app).not.toContain('task-detail-trigger')
expect(css).not.toContain('.task-detail-trigger')
})
it('uses the approved paper-flow task detail structure and order', () => {
const detail = app.slice(app.indexOf('<AppSheet v-if="selectedTask"'), app.indexOf('</AppSheet>', app.indexOf('<AppSheet v-if="selectedTask"')))
const orderedMarkers = [
'class="detail-head"',
'class="detail-form"',
'class="detail-title"',
'class="task-detail-arrangement"',
'class="field markdown task-detail-notes"',
'class="subtasks task-detail-subtasks"',
'class="more-settings"',
'class="detail-actions"',
]
let cursor = -1
for (const marker of orderedMarkers) {
const next = detail.indexOf(marker)
expect(next).toBeGreaterThan(cursor)
cursor = next
}
expect(detail).toContain('<span class="task-detail-field-label">清单</span>')
expect(detail).toContain('<span class="task-detail-field-label">截止日期</span>')
expect(detail).toContain('<span class="task-detail-field-label">时间</span>')
expect(detail).toContain('<span class="task-detail-field-label">重复</span>')
expect(detail).toContain('<button class="danger-text detail-trash"')
expect(detail).toContain('<button class="primary detail-save"')
})
it('locks the task detail flow and exposes safe subtask removal semantics', () => {
const detail = app.slice(app.indexOf('<AppSheet v-if="selectedTask"'), app.indexOf('</AppSheet>', app.indexOf('<AppSheet v-if="selectedTask"')))
expect(detail).toContain(':busy="taskDetailBusy"')
expect(detail).toContain('@submit.prevent="saveSelectedTaskChanges"')
expect(detail).toContain('<button class="icon" type="button" :disabled="taskDetailBusy"')
expect(detail).toContain('<fieldset class="detail-form" :disabled="taskDetailBusy">')
expect(detail).toContain('class="subtask-remove" type="button"')
expect(detail).toContain('@click="removeSubtask(subtask)"')
expect(detail).toContain('<button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy"')
expect(detail).toContain('<button class="primary detail-save" type="submit"')
const buttons = [...detail.matchAll(/<button\b([^>]*)>/g)]
expect(buttons.length).toBeGreaterThan(0)
for (const [, attributes] of buttons) expect(attributes).toMatch(/\btype="(?:button|submit)"/)
const removeBlock = app.slice(app.indexOf('async function removeSubtask('), app.indexOf('function closeTaskDetail('))
expect(removeBlock).toContain('if (taskDetailBusy.value) return')
expect(removeBlock).toContain("() => api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('await taskMutationReconciler.run(')
expect(removeBlock).toContain('{ affectsTaskView: true }')
expect(removeBlock).not.toContain('affectsTrash: true')
expect(removeBlock).toContain('selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter')
expect(removeBlock).not.toContain('tasks.value = tasks.value.filter')
expect(removeBlock).not.toContain('selectedTask.value = null')
})
it('matches the approved paper-flow geometry and material', () => {
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}')
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:18px;border-bottom:1px solid #e4dbcf}')
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:10px}')
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
expect(css).toContain('.detail-save{min-width:88px}')
expect(css).toContain('.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}')
expect(css).toContain('.subtask-remove{width:44px;height:44px;flex:0 0 44px;')
expect(css).toContain('@media(max-width:930px){.detail{position:relative;z-index:auto;left:auto;right:auto;top:auto;bottom:auto;width:100%;max-height:min(88dvh,760px);')
expect(css).toContain('.task-detail-date-time{grid-template-columns:minmax(0,1fr)}')
expect(css).toContain('.task-detail-date-control .task-compose-date-clear,.task-detail-time-control .task-compose-time-remove{min-width:44px;min-height:44px;width:44px;height:44px}')
})
it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => {
const todayRows = mvpPanel.slice(mvpPanel.indexOf('class="habit-list plain-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
expect(todayRows).toContain('role="button" tabindex="0"')
expect(todayRows).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(todayRows).toContain('@click.stop="toggleHabitFromButton(h)"')
})
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}')
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
})
it('compresses countdown focus without hiding its primary information', () => {
expect(css).toMatch(/\.countdown-focus\{[^}]*min-height:140px/)
expect(css).toMatch(/\.countdown-number strong\{[^}]*font-size:64px/)
expect(countdownPanel).toContain('<h3>{{focusItem.title}}</h3>')
expect(countdownPanel).toContain('<p>{{primaryDate(focusItem)}}</p>')
})
it('does not expose task or memo search controls', () => {
expect(app).not.toContain('task-search-panel')
expect(app).not.toContain('mobileSearchOpen')
expect(readFileSync('src/MemoPanel.vue', 'utf8')).not.toContain('mobileSearchOpen')
expect(readFileSync('src/MemoPanel.vue', 'utf8')).not.toContain('搜索备忘录')
})
it('does not expose manual refresh controls', () => {
expect(app).not.toContain('topbar-refresh')
expect(app).not.toContain('refreshCurrentView')
expect(app).not.toContain('aria-label="刷新当前页面"')
expect(css).not.toContain('.topbar-refresh{')
})
})
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('gives overdue tasks the same right-swipe completion motion as regular tasks and habits', () => {
const overdueBlock = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('<button id="today-tasks-heading"'))
expect(overdueBlock).toContain('class="task-row overdue-task swipeable"')
expect(overdueBlock).toContain("'just-completed':justCompletedTaskIds.has(node.task.id)")
expect(overdueBlock).toContain("ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64")
expect(overdueBlock).toContain("'--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`")
expect(overdueBlock).toContain('@pointerdown="startTaskPointer(node.task, $event)"')
expect(overdueBlock).toContain('@touchstart.passive="startTaskSwipe(node.task, $event)"')
expect(overdueBlock).toContain('@click="selectTaskUnlessSwiped(node.task)"')
expect(overdueBlock).toContain('@keydown.space.prevent="selectTaskUnlessSwiped(node.task)"')
})
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('stacks the desktop calendar copy to align with weather and gold', () => {
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:2px;font-size:10.5px}}')
})
it('uses the approved internal-standard login composition and material tokens', () => {
expect(app).toContain('class="auth-brand"')
expect(app).toContain('class="brand brand-lockup"')
expect(app).toContain('<h1 id="auth-title">{{ initialized ? \'欢迎回来\' : \'创建你的 dodo\' }}</h1>')
expect(app).toContain("{{ initialized ? '登录后继续处理今天的事项。' : '创建账号后开始整理你的事项。' }}")
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
expect(app).toContain('<button type="submit" class="primary">')
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:36px[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
expect(css).toContain('.auth-card>.primary{min-height:46px}')
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:18px\}\.auth-card\{padding:30px 24px\}/)
})
it('syncs 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('provides a practical task-note Markdown toolbar with editing and preview modes', () => {
expect(app).toContain('ref="taskNoteEditor"')
expect(app).toContain('class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式"')
for (const label of ['标题', '粗体', '斜体', '无序列表', '有序列表', '待办', '链接', '行内代码', '代码块', '引用']) {
expect(app).toContain(`aria-label="${label}"`)
}
expect(app).toContain('@click="formatTaskNote(')
expect(app).toContain('@keydown="handleTaskNoteShortcut"')
expect(app).toContain('class="markdown-mode-switch"')
expect(app).toContain(':aria-pressed="!markdownPreview"')
expect(app).toContain(':aria-pressed="markdownPreview"')
expect(css).toContain('.markdown-toolbar{display:flex;')
expect(css).toContain('overflow-x:auto')
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:3px}')
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:24px}')
})
it('keeps all task detail changes on the unified save button', () => {
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')
expect(saveTaskBlock).toContain('selectedDueHasTime.value = Boolean(updated.due_has_time)')
const unifiedSaveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges('), app.indexOf('async function removeTask('))
expect(unifiedSaveBlock).toContain('if (!taskSaved.due_at) {')
expect(unifiedSaveBlock).toContain('selectedTaskRecurrence.value = null')
expect(unifiedSaveBlock).toContain("selectedTaskRepeat.value = 'none'")
const dueRemovalBlock = unifiedSaveBlock.slice(unifiedSaveBlock.indexOf('if (!taskSaved.due_at) {'), unifiedSaveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
})
it('keeps the desktop task composer compact and centered', () => {
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:24px}')
})
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', handleViewportResize)")
expect(app).toContain('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('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
expect(css).toContain('.app-overlay>.detail{transform:none}')
})
it('uses shared AppSheet variants for details and creation', () => {
expect(app).toContain('panel-class="task-compose-sheet"')
expect(app).toContain('variant="create"')
expect(app).not.toContain('class="more-sheet app-sheet app-sheet--actions"')
expect(mvpPanel).toContain('panel-class="task-compose-sheet habit-compose-sheet"')
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
expect(countdownPanel).toContain('panel-class="countdown-modal"')
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 habit-detail-archived-actions"')
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('panel-class="habit-detail-sheet"')
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("if (!(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return")
expect(mvpPanel).toContain('if (busy.value) return\n busy.value = true')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)")
expect(mvpPanel).toContain('aria-labelledby="habit-history-title"')
expect(mvpPanel).toContain('历史记录加载失败')
expect(mvpPanel).toContain('加载更早记录')
expect(mvpPanel).toContain('mergeHabitHistory(reset ? [] : habitHistory.value')
expect(css).toContain('.habit-history__row{min-height:48px')
expect(css).toContain('.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);overflow:hidden')
expect(mvpPanel).toContain("habitHistoryNextTo.value = ''")
expect(mvpPanel).toContain('habitHistoryHasMore.value = false')
expect(mvpPanel).toContain('syncHabitHistoryToday(h, day)')
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('initial-focus="button[aria-label=\'关闭习惯详情\']"')
expect(mvpPanel).not.toContain('habitDetailSheet')
})
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('shows task drag handles only in an explicit available reorder mode', () => {
expect(app).toContain('const taskReorderMode = ref(false)')
expect(app).toContain("const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)")
expect(app).toContain('class="soft-button reorder-mode-toggle task-reorder-toggle"')
expect(app).toContain("{{ taskReorderMode ? '完成' : '调整顺序' }}")
expect(app).toContain('v-if="taskReorderMode" class="drag-handle task-drag-handle"')
expect(app).toContain('if (!taskReorderAvailable.value) taskReorderMode.value = false')
expect(app).toContain('taskReorderMode.value = false')
})
it('shows habit drag handles only in explicit available reorder mode and never changes Today habits', () => {
expect(mvpPanel).toContain('const habitReorderMode = ref(false)')
expect(mvpPanel).toContain("const habitReorderAvailable = computed(() => props.view === 'habits' && visibleHabits.value.length > 1)")
expect(mvpPanel).toContain('v-if="habitReorderAvailable" class="list-section-action"')
expect(mvpPanel).toContain('class="list-section-heading habit-section-heading"')
expect(mvpPanel).toContain("{{ habitReorderMode ? '完成' : '调整顺序' }}")
expect(mvpPanel).toContain('v-if="habitReorderMode && habitReorderAvailable" class="drag-handle habit-drag-handle"')
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
expect(todayHabits).not.toContain('reorder-mode-toggle')
expect(todayHabits).not.toContain('habit-drag-handle')
})
it('reuses the task row completion control in task details', () => {
const detailTitle = app.slice(app.indexOf('<div class="detail-title">'), app.indexOf('</div>', app.indexOf('<div class="detail-title">')))
expect(detailTitle).toContain('class="task-check detail-task-check"')
expect(detailTitle).toContain(':aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`"')
expect(detailTitle).toContain(':aria-pressed="selectedTask.completed"')
expect(detailTitle).toContain('<span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span>')
expect(detailTitle).not.toContain('class="check large"')
expect(css).toContain('.detail-title .detail-task-check{width:44px;height:44px;flex:0 0 44px;')
})
it('removes English eyebrows from task and habit composers', () => {
expect(app).not.toContain('<small>NEW TASK</small>')
expect(mvpPanel).not.toContain("<small>{{ editingHabit ? 'EDIT HABIT' : 'NEW HABIT' }}</small>")
expect(app).toContain('<h2 id="task-compose-title">{{ taskComposeTitle }}</h2>')
expect(mvpPanel).toContain('<h2 id="habit-compose-title">{{ habitComposerTitle }}</h2>')
})
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('await taskMutationReconciler.run(')
expect(mutationBlock).toContain('{ affectsTrash: true, affectsTaskView }')
expect(mutationBlock).not.toContain('performTrashMutation(')
expect(mutationBlock).not.toContain('loadTrash')
expect(restoreBlock).toContain("await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`")
expect(restoreBlock).toContain("'任务已恢复', true")
expect(purgeBlock).toContain("await mutateTrashTask(task, () => api(`/trash/${task.id}`")
expect(purgeBlock).toContain("'任务已永久删除', false")
})
it('supports pointer dragging for desktop task completion in regular and overdue rows', () => {
expect(app.match(/@pointerdown="startTaskPointer/g)?.length).toBe(2)
expect(app.match(/@pointermove="moveTaskPointer/g)?.length).toBe(2)
expect(app.match(/@pointerup="finishTaskPointer/g)?.length).toBe(2)
expect(app.match(/@pointercancel="cancelTaskPointer/g)?.length).toBe(2)
})
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', () => {
const mutationBlock = mvpPanel.slice(mvpPanel.indexOf('async function mutateHabitValue'), mvpPanel.indexOf('function suppressHabitDetailClick'))
const applyBlock = mvpPanel.slice(mvpPanel.indexOf('async function applyHabitSwipe'), mvpPanel.indexOf('function suppressHabitDetailClick'))
expect(mutationBlock).toContain('setLocalHabitValue(h, next, day)')
expect(mutationBlock).toContain('setLocalHabitValue(h, rollback, day)')
expect(applyBlock).not.toContain('await loadHabits()')
expect(applyBlock).toContain('mutateHabitValue(h, next')
})
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"')
const subtaskRow = app.slice(app.indexOf('<div v-for="subtask in selectedTaskSubtasks"'), app.indexOf('</div><span v-if="!selectedTaskSubtasks.length"'))
expect(subtaskRow).toContain('class="subtask-detail"')
expect(subtaskRow).not.toMatch(/class="subtask-detail"[^>]*@click=/)
expect(subtaskRow).toContain('class="task-check subtask-check"')
expect(subtaskRow).toContain('<span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span>')
expect(subtaskRow).toContain('@click="toggle(subtask)"')
expect(css).toContain('.subtask-detail .subtask-check{width:44px;height:44px;flex:0 0 44px;')
expect(css).toContain('.task-check{')
expect(css).toContain('.task-check-mark{')
expect(css).toContain('.task-check:focus-visible{')
})
it('updates a nested subtask locally after its completion changes', () => {
const updateBlock = app.slice(app.indexOf('function applyTaskUpdate'), app.indexOf('async function patchTask'))
expect(updateBlock).toContain('subtasks: item.subtasks?.map')
expect(updateBlock).toContain('subtask.id === task.id ? { ...subtask, ...updated } : subtask')
expect(updateBlock).toContain('selectedTask.value.subtasks = selectedTask.value.subtasks.map')
})
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 = updated.completed')
expect(toggleBlock).toContain('await taskToggleCoordinator.toggle(')
expect(toggleBlock).toContain("toast(completing ? '完成啦' : '已重新打开')")
expect(toggleBlock).toContain('applyTaskUpdate(task, updated, !preserveSelectedDraft)')
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('mutateHabitValue(h, next')
expect(toggleBlock).not.toContain('await loadHabits()')
})
it('keeps habit status in the right-side tail and outside the title', () => {
expect(mvpPanel.match(/class="habit-row-meta"/g)?.length).toBe(2)
expect(mvpPanel).not.toContain('class="habit-progress"')
expect(mvpPanel).not.toContain('<span><span class="habit-name">{{ h.name }}</span><small')
})
it('keeps weekly history out of both active list renderers', () => {
expect(mvpPanel).not.toContain('class="habit-week"')
expect(mvpPanel).not.toContain('v-for="cell in h.cells"')
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
})
it('shows independently accessible progress tracks for numeric habits in Today and full habits', () => {
expect(mvpPanel.match(/<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2)
expect(mvpPanel.match(/:value="habitProgressValue\(h\)"/g)?.length).toBe(2)
expect(mvpPanel.match(/:max="habitProgressMax\(h\)"/g)?.length).toBe(2)
expect(mvpPanel.match(/<\/div>\n\s*<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2)
expect(mvpPanel.match(/class="habit-row-meta"/g)?.length).toBe(2)
expect(mvpPanel.match(/:aria-label="habitRowStatus\(h\)"/g)?.length).toBe(2)
expect(mvpPanel).not.toContain('class="habit-state-note"')
expect(css).toContain('.habit-row-meta{')
})
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 completed filter per supported view and keeps App as the only preference owner', () => {
expect(app).toContain("import CompletedFilterPill from './components/CompletedFilterPill.vue'")
const topbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>', app.indexOf('<header class="topbar"')))
expect(topbar.match(/<CompletedFilterPill/g) ?? []).toHaveLength(0)
expect(topbar).not.toContain('topbar-filter')
expect(app).not.toContain("v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\"")
expect(app).toContain('class="today-inline-filter"')
expect(app).toContain('class="list-inline-filter"')
expect(mvpPanel).toContain('class="completed-filter-pill habit-inline-filter"')
expect(app).toContain('v-model="showCompleted"')
expect(app).not.toContain('class="topbar-actions"')
expect(app).not.toContain('topbar-refresh')
expect(app).not.toContain('aria-label="刷新当前页面"')
expect(app).not.toContain('refreshCurrentView')
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).toContain('@update:show-completed="showCompleted=$event"')
expect(mvpPanel).toContain('showCompleted: boolean')
expect(mvpPanel).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 the compact two-column topbar without search or refresh affordances', () => {
expect(css).toContain('main{container-type:inline-size;')
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr);grid-template-areas:"menu title";')
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(app).not.toContain('task-search-panel')
expect(app).not.toContain('search-reveal')
expect(app).not.toContain('startSearchPull')
expect(app).not.toContain('searchInput')
expect(app).not.toContain('topbar-refresh')
expect(css).not.toContain('.search-reveal{')
expect(css).not.toContain('.topbar-refresh{')
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 while keeping visible habits sortable', () => {
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).not.toContain(':disabled="!showCompleted"')
expect(mvpPanel).toContain('if (!habitReorderMode.value || !habitReorderAvailable.value || busy.value) return')
expect(mvpPanel).not.toContain('if (busy.value || !props.showCompleted) return')
expect(mvpPanel).toContain('const reorderedVisible = moveItemWithinScope(visiblePrevious')
expect(mvpPanel).toContain('const next = mergeReorderedSubset(previous, reorderedVisible)')
expect(mvpPanel).toContain('habit_ids: reorderedVisible.map((item) => item.id)')
expect(css).not.toContain('.habit-toolbar{')
})
it('uses a compact hidden-completed notice only on Today', () => {
expect(app).toContain("v-if=\"activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading\" class=\"today-filtered-empty-note\"")
expect(app).toContain('已隐藏已完成任务')
expect(app).toContain('@click="showCompleted=true">显示</button>')
expect(app).toContain("v-else-if=\"!visibleTasks.length&&!loading\" class=\"empty\"")
expect(css).toContain('.today-filtered-empty-note{min-height:48px;')
expect(css).toContain('@media(max-width:930px){.today-filtered-empty-note{min-height:44px;')
})
it('distinguishes hidden completed items from the true empty state', () => {
expect(app).toContain('const hiddenCompletedTaskCount = ref(0)')
expect(app).toContain("hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
expect(app).not.toContain('没有匹配的任务')
expect(mvpPanel).toContain("!showCompleted && todayHabits.length ? '已完成的习惯已隐藏。'")
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
})
it('includes tasks actually completed today in Today even when they were overdue', () => {
expect(app).toContain('completed_at: string | null')
expect(app).toContain("const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()")
expect(app).toContain('return Boolean(dueToday || completedToday)')
expect(app).toContain("params.set('completed_from', isoAtLocalDayOffset(0))")
expect(app).toContain("params.set('completed_to', isoAtLocalDayOffset(1))")
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('reconcile: async (confirmed, ownership) =>')
expect(toggleBlock).toContain('await reconcileCurrentViewAfterTaskMutation()')
expect(toggleBlock).not.toContain('await loadAll()')
})
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 today-collapsible-section"')
expect(app).toContain('<span class="today-section-title">逾期</span>')
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 continuous list with direct empty-state actions', () => {
expect(app).toContain('class="today-context"')
expect(app).toContain('还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成')
expect(app).not.toContain('class="today-board"')
expect(app).not.toContain('class="today-track today-task-track"')
expect(app).not.toContain('class="today-track today-habit-track"')
expect(app).not.toContain(':style="{ width: todayTaskProgressPercent }"')
expect(app).not.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).not.toContain('role="progressbar"')
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).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-context{')
expect(css).toContain('.today-remaining{')
expect(css).not.toContain('.today-board{')
expect(css).not.toContain('.today-track{')
expect(css).not.toContain('.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 settings-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 every plain-list container completely cardless', () => {
expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
expect(css).not.toMatch(/\.plain-list\{[^}]*padding:/)
expect(css).not.toMatch(/\.plain-list\{[^}]*border:(?!0)/)
expect(css).not.toMatch(/\.plain-list\{[^}]*border-radius:(?!0)/)
expect(css).not.toMatch(/\.plain-list\{[^}]*box-shadow:(?!none)/)
})
it('uses a continuous bottom divider instead of card borders', () => {
expect(css).toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border:0;[^}]*border-bottom:1px solid #e8e0d5/)
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-top:/)
expect(css).not.toMatch(/\.plain-list-row,\.plain-list>\.task-row,\.plain-list>\.habit-row\{[^}]*border-right:/)
})
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('uses one page title and continuous settings section headings without slogan strips', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).not.toContain('class="view-intro"')
for (const title of ['数据与恢复', '账户与安全', '登录设备', '活动记录']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
expect(mvpPanel).not.toContain('<h2>危险操作</h2>')
expect(mvpPanel).toContain('class="settings-sections"')
expect(mvpPanel).not.toContain('class="settings-grid"')
expect(mvpPanel).not.toContain('class="tool-card')
expect(css).toContain('.settings-sections{width:min(100%,900px);')
expect(css).toContain('.settings-row{min-height:62px;')
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
expect(css).toContain('.backup-preflight.invalid{background:#fff2ef;')
expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"")
expect(mvpPanel).toContain('替换恢复会覆盖当前数据,请先导出完整备份。')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const appDialog = ref')
expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
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, rollback, day)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})
describe('settings data tools', () => {
it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
expect(mvpPanel).toContain('<h2>数据与恢复</h2>')
expect(mvpPanel).toContain("downloadFullBackup()")
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
expect(mvpPanel).toContain('导出 ZIP')
expect(mvpPanel).toContain('accept=".zip,.csv,.json')
expect(mvpPanel).toContain('v-model="restoreMode"')
expect(mvpPanel).toContain('runPreflight')
expect(mvpPanel).toContain('restorePreflight.valid')
expect(mvpPanel).toContain('preflight_token')
expect(mvpPanel).toContain('await restoreBackup')
expect(mvpPanel).toContain("await uploadJson('/restore.csv?mode=merge'")
expect(mvpPanel).toContain("await requestJson('/restore?mode=merge'")
expect(mvpPanel).toContain('const snapshot = backupFileSnapshot(file, restoreMode.value)')
expect(mvpPanel).toContain('shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)')
expect(mvpPanel).toContain('preflightController?.abort()')
expect(mvpPanel).toContain("旧格式将在恢复时校验,不支持完整预检或 Replace。")
expect(mvpPanel).toContain('<option v-if="!legacyRestore" value="replace">')
expect(mvpPanel).toContain("emit('changed'); emit('notice', '数据已恢复')")
expect(mvpPanel).not.toContain("request('/import/ticktick")
})
})
describe('habit detail paper-flow redesign', () => {
it('keeps the approved information order and every existing detail state/action', () => {
const sheet = mvpPanel.slice(mvpPanel.indexOf('<AppSheet :open="Boolean(selectedHabit)"'), mvpPanel.indexOf('</AppSheet>', mvpPanel.indexOf('<AppSheet :open="Boolean(selectedHabit)"')))
const detail = sheet.slice(sheet.indexOf('<template v-if="selectedHabit">'))
const ordered = [
'habit-detail-archive-note',
'habit-detail-title',
'habit-detail-hero',
'habit-detail-progress-row',
'记录方式',
'计划',
'开始日期',
'habit-history-title',
].map((token) => detail.indexOf(token))
expect(ordered.every((position) => position >= 0)).toBe(true)
expect(ordered).toEqual([...ordered].sort((a, b) => a - b))
expect(detail).toContain('selectedHabitDetailProgress.primary')
expect(detail).toContain('selectedHabitDetailProgress.note')
expect(detail).toContain('formatHabitRecordMode(selectedHabit)')
expect(detail).toContain('formatHabitSchedule(selectedHabit)')
expect(detail).toContain('formatHabitDetailDate(selectedHabit.start_date)')
expect(detail).not.toContain('最后记录于')
expect(detail).toContain('habitHistoryLoading')
expect(detail).toContain('habitHistoryError')
expect(detail).toContain('loadHabitHistory(true)')
expect(detail).toContain('loadHabitHistory(false)')
expect(detail).toContain('@click="editHabit(selectedHabit)"')
expect(detail).toContain('@click="archiveHabit(selectedHabit)"')
expect(detail).toContain('@click="restoreHabit(selectedHabit)"')
expect(detail).toContain('@click="deleteHabit(selectedHabit)"')
})
it('matches the paper-flow surface and action hierarchy at desktop and touch widths', () => {
expect(css).toContain('/* Approved habit detail 01: paper flow. */')
expect(css).toMatch(/\.habit-detail-hero\{[^}]*padding:[^;}]*;[^}]*border-bottom:1px solid #e4dbcf/)
expect(css).toMatch(/\.habit-detail-hero strong\{[^}]*font-size:28px/)
expect(css).toMatch(/\.habit-detail-progress-row\{[^}]*min-height:66px;[^}]*grid-template-columns:minmax\(0,1fr\) auto/)
expect(css).toMatch(/\.habit-detail-meta-row\{[^}]*min-height:52px;[^}]*grid-template-columns:86px minmax\(0,1fr\)/)
expect(css).toMatch(/\.habit-detail-active-actions\{[^}]*justify-content:space-between/)
expect(css).toMatch(/\.habit-detail-archive-button\{[^}]*background:transparent;[^}]*color:var\(--danger\)/)
expect(css).toMatch(/@media\(max-width:930px\)\{[^}]*\.habit-detail-sheet\.app-sheet--detail\{[^}]*width:100%/)
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.habit-detail-sheet :is\([^}]*\)\{[^}]*min-height:44px/)
})
})
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, date, time, and repeat controls in the paper arrangement group', () => {
expect(app).toContain('<section class="task-detail-arrangement" aria-label="安排">')
expect(app).toContain('<div class="task-detail-date-time">')
expect(app).toContain('v-model="selectedDueDate" 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:100%!important;min-height:44px;border-radius:10px}')
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('<span class="task-detail-field-label">重复</span><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 = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
expect(saveBlock).toContain('JSON.parse(JSON.stringify(selectedRepeatConfig.value))')
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
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, recurrence)'))
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
expect(saveBlock).toContain('savingSelectedTask.value = true')
expect(saveBlock).toContain('} finally {\n savingSelectedTask.value = false\n }')
expect(saveBlock).not.toContain("if (recurrenceLoadToken === selectionToken && selectedTask.value?.id === taskId) savingSelectedTask.value = false")
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
expect(saveTaskBlock).toContain('if (selectionMatches && selectedTask.value?.id === task.id) fail(reason)')
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
expect(app).toContain('type="submit" :disabled="taskDetailBusy"')
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("if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return")
expect(floatingAdd).toContain('@pointermove="moveDrag"')
expect(floatingAdd).toContain("position.value = snapFabPosition(")
expect(floatingAdd).toContain('snapFabToCurrentViewport')
expect(floatingAdd).toContain("window.addEventListener('resize', snapFabToCurrentViewport)")
expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging')
expect(css).toContain('.unified-fab.snapping')
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
expect(countdownPanel).toContain('panel-class="countdown-modal"')
expect(css).toMatch(/@media\(max-width:930px\)\{[^}]*\.bottom\{[^}]*height:calc\(56px \+ var\(--safe-area-bottom\)\)[\s\S]*?\.unified-fab\{bottom:calc\(68px \+ var\(--safe-area-bottom\)\)\}/)
})
it('renders the original orange circular plus button', () => {
expect(floatingAdd).toContain("import { Plus } from 'lucide-vue-next'")
expect(floatingAdd).toContain('<Plus />')
expect(floatingAdd).not.toContain('fab-cat')
expect(floatingAdd).not.toContain('pupilOffset')
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
expect(css).toContain('.unified-fab>svg{width:25px;height:25px}')
expect(css).not.toContain('.fab-cat__')
})
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 and habit detail disclosure', () => {
it('gives the list the full remaining width until a task or habit is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen")
expect(app).toContain(':style="shellStyle"')
expect(app).toContain('aria-label="调整菜单栏宽度"')
expect(app).toContain(':aria-label="detailSeparatorLabel"')
expect(app).toContain(':aria-valuemax="sidebarMaxWidth"')
expect(app).toContain(':aria-valuemax="detailMaxWidth"')
expect(app).toContain('@lostpointercapture="handlePaneLostPointerCapture"')
expect(app).toContain("@pointerdown=\"startPaneResize('sidebar',$event)\"")
expect(app).toContain("@pointerdown=\"startPaneResize('detail',$event)\"")
expect(app).toContain("@keydown=\"resizePaneWithKeyboard('sidebar',$event)\"")
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
expect(app).toContain('panel-class="detail"')
expect(app).toContain(':compact-layout="compactLayout"')
expect(app).toContain('@detail="handleHabitDetail"')
expect(app).toContain('function handleHabitDetail(open: boolean)')
expect(app).toContain('if (open) closeTaskDetail()')
expect(app).toContain('habitComposer.value?.closeHabitDetail(true)')
expect(mvpPanel).toContain('function closeHabitDetail(force = false)')
expect(mvpPanel).toContain('if (busy.value && !force) return')
expect(mvpPanel).toContain(':modal="compactLayout"')
expect(mvpPanel).toContain('inline-target=".shell"')
expect(appSheet).toContain('<Teleport v-else-if="inlineTarget" :to="inlineTarget">')
expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))")
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:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(330px,1fr) var(--detail-width,350px)}')
expect(css).toContain('.pane-resizer{position:relative;z-index:12;width:9px;')
expect(css).toContain('@media(min-width:931px){.habit-detail-sheet{width:var(--detail-width,350px);height:100vh;')
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(330px,1fr) 0}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(330px,1fr) var(--detail-width,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:68px;')
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('groups folder and list editing actions into a clear compact hierarchy', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('panel-class="sidebar-action-sheet"')
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
expect(app).toContain('class="sidebar-action-kind"')
expect(app).toContain('class="sidebar-action-group"')
expect(app).toContain('class="sidebar-action-group-title"')
expect(app).toContain('class="sidebar-action-danger"')
expect(app).toContain("sidebarAction.kind==='folders'?'文件夹':'清单'")
expect(app).toContain("sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'")
expect(css).toContain('.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));')
expect(css).toContain('.sidebar-action-group{display:grid;gap:2px;')
expect(css).toContain('.sidebar-action-danger{border-top:1px solid')
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')
})
it('uses a dedicated second step for choosing a list destination', () => {
expect(app).toContain('<template v-if="listMoveMenuOpen">')
expect(app).toContain('aria-label="返回清单操作"')
expect(app).toContain('class="sidebar-action-move-title"')
expect(app).toContain('选择目标位置')
expect(app).toContain('role="menu" aria-label="选择目标文件夹"')
expect(app).toContain("'list-move-current':")
expect(css).toContain('.sidebar-action-move-back{min-height:44px;')
expect(css).toContain('.list-move-menu{display:grid;gap:2px;padding:0}')
})
})
describe('quiet index sidebar parity', () => {
it('matches the approved desktop and mobile sidebar geometry and material', () => {
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
expect(css).toContain('.sidebar{border-right:1px solid #e4d5c3;background:#f7efe3;')
expect(css).toContain('.brand-row{height:68px;')
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
expect(css).not.toContain('grid-template-columns:220px')
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).toContain('left:0;width:236px;max-width:86vw;')
})
it('matches the approved quiet navigation and list hierarchy', () => {
expect(css).toContain('.primary-nav button.active{background:#f9e2d5;color:#b63c1b;')
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
expect(css).toContain('width:3px;height:18px;')
expect(css).toContain('.section-title{padding:14px 15px 5px 20px;')
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 10px;')
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 8px;')
expect(css).toContain('.list-row.active{background:#fff8ef;color:#bc431f;box-shadow:inset 0 0 0 1px #ead5c4}')
})
it('removes decorative markers from both ordinary list row renderers', () => {
expect(app.match(/class="list-row-main draggable-list-row-main"/g)).toHaveLength(2)
expect(app).not.toMatch(/class="list-row-main draggable-list-row-main"[^>]*><i\s*\/>/)
expect(css).not.toContain('.list-row i')
})
})
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('keeps folder touch targets at least 44px high', () => {
const folderButtonRule = css.match(/\.folder-row>button,\.list-row-main\{([^}]*)\}/)?.[1]
expect(folderButtonRule).toContain('min-height:44px')
})
it('keeps the final mobile row-action touch target at least 44px high', () => {
const rowActionRules = [...css.matchAll(/\.row-actions button\{([^}]*)\}/g)]
const finalRowActionRule = rowActionRules.at(-1)?.[1]
expect(finalRowActionRule).toContain('min-height:44px')
})
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('panel-class="purge-list-dialog"')
expect(app).toContain('initial-focus=".secondary"')
expect(app).toContain(':busy="purgeListSubmitting"')
expect(app).toContain('@close="closePurgeList"')
expect(app).not.toContain('handlePurgeDialogKeydown')
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)")
})
})
describe('original circular floating add button', () => {
it('keeps only the plus icon while preserving drag and activation contracts', () => {
expect(floatingAdd).toContain('<Plus />')
expect(floatingAdd).not.toContain('fab-cat')
expect(floatingAdd).not.toContain('pupilOffset')
expect(floatingAdd).toContain('let suppressClickTimer: number | undefined')
expect(floatingAdd).toContain('isFabDrag(deltaX, deltaY)')
expect(floatingAdd).toContain('if (suppressClickTimer) window.clearTimeout(suppressClickTimer)')
expect(floatingAdd).toContain("emit('activate', { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })")
})
it('uses the original orange circle visuals and restrained motion', () => {
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
expect(css).toContain('.unified-fab.dragging{transform:scale(1.06);')
expect(css).toContain('.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}')
expect(css).toContain('.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}')
expect(css).not.toContain('.fab-cat__')
})
it('keeps reduced-motion handling without cat-only behavior', () => {
expect(floatingAdd).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches")
expect(floatingAdd).toContain('if (prefersReducedMotion()) return')
expect(floatingAdd).not.toContain('pupilOffset')
})
})