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