1802 lines
117 KiB
TypeScript
1802 lines
117 KiB
TypeScript
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')
|
||
})
|
||
})
|