Files
dodo/frontend/src/style.test.ts
T
bboysoul 125e6d3a61
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
fix: align archived habits on mobile
2026-09-10 09:16:39 +08:00

885 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { createCompletionPulse } 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')
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('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('solid cream material system', () => {
it('defines three opaque cream surfaces, warm borders, restrained depth, and the radius scale', () => {
expect(css).toContain('--surface-canvas:#f6f0e5')
expect(css).toContain('--surface-base:#fff9ef')
expect(css).toContain('--surface-raised:#fffdf8')
expect(css).toContain('--border-cream:#e5d7c3')
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('completion feedback motion', () => {
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}}')
})
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 a minimal archived-habit viewing path', () => {
expect(mvpPanel).toContain("request('/habits?archived=true')")
expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}")
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-toggle\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\)/)
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
expect(css).toMatch(/\.archived-habits>button\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*grid-template-columns:minmax\(0,1fr\) auto;[^}]*background:var\(--surface-raised\);[^}]*border-bottom:1px solid var\(--border-cream\)/)
expect(css).toMatch(/\.archived-habits>button span\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
expect(css).toMatch(/\.archived-habits>button small\{[^}]*white-space:nowrap/)
})
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 tasks and habits', () => {
expect(app.match(/class="drag-handle task-drag-handle"/g)?.length).toBe(2)
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('supports pointer dragging for desktop task and subtask completion', () => {
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', () => {
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('collapses and expands subtasks when the parent task is clicked', () => {
expect(app).toContain('const collapsedTaskIds = ref(new Set<string>())')
expect(app).toContain('function toggleTaskChildren(task: Task)')
expect(app).toContain('selectTaskUnlessSwiped(node.task, true)')
expect(app).toContain('v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks"')
expect(app).toContain(':aria-expanded="!collapsedTaskIds.has(node.task.id)"')
})
it('starts parent tasks collapsed when entering a task list or upcoming view', () => {
expect(app).toContain('function collapseLoadedTaskChildren()')
expect(app).toContain("else if (isTaskView(activeView.value)) {\n await loadAll()\n if (activeView.value === 'tasks' || activeView.value === 'upcoming') collapseLoadedTaskChildren()")
expect(app).toContain("else {\n await loadAll()\n if (view === 'tasks' || view === 'upcoming') collapseLoadedTaskChildren()")
expect(app).toContain('tasks.value.filter((task) => task.subtasks?.length).map((task) => task.id)')
})
it('shows visible completion buttons for tasks and subtasks while keeping swipe shortcuts', () => {
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(3)
expect(app).toContain('class="task-check-mark"')
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:linear-gradient')
})
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 positive completed-item preference across task and habit views', () => {
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(mvpPanel).not.toContain('HIDE_COMPLETED_HABITS_STORAGE_KEY')
expect(mvpPanel).not.toContain('dodo.hide-completed-habits')
})
it('shows one positive switch before Today content and no switch inside today habits', () => {
expect(app).toContain('class="list-toolbar today-completed-toolbar"')
expect(app.indexOf('class="list-toolbar today-completed-toolbar"')).toBeLessThan(app.indexOf('class="today-board"'))
expect(app).toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
expect(todayHabits).not.toContain('type="checkbox"')
expect(todayHabits).not.toContain('显示已完成')
expect(todayHabits).not.toContain('隐藏已完成')
})
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).toContain('.habit-toolbar{')
})
it('distinguishes hidden completed items from true and search empty states', () => {
expect(app).toContain('const hiddenCompletedTaskCount = ref(0)')
expect(app).toContain("query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
expect(mvpPanel).toContain("!showCompleted && todayHabits.length ? '已完成的习惯已隐藏。'")
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
})
it('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('@media(max-width:390px){.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('rrule,')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
expect(app).toContain('<option value="custom">自定义…</option>')
expect(app).toContain('class="repeat-custom-fields"')
expect(app).toContain('每隔')
expect(app).toContain('重复日期')
expect(app).toContain('结束方式')
expect(app).toContain('重复次数')
})
it('preloads countdowns through the shared cache after authentication', () => {
expect(app).toContain('void preloadCountdowns()')
expect(app).toContain('await loadCountdownCache(async () =>')
expect(app).toContain("Promise.all([api('/countdowns'), api('/countdowns?archived=true')])")
})
it('reuses one draggable FAB component for task, habit, and countdown views', () => {
expect(app).toContain("import FloatingAddButton from './components/FloatingAddButton.vue'")
expect(app).toContain('<FloatingAddButton')
expect(floatingAdd).toContain('class="unified-fab"')
expect(floatingAdd).toContain('@pointerdown="startDrag"')
expect(floatingAdd).toContain('@pointermove="moveDrag"')
expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging')
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
expect(css).toContain('.countdown-compose-enter-active')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
})
it('keeps the shared FAB visually simple', () => {
expect(floatingAdd).not.toContain('unified-fab-aura')
expect(floatingAdd).not.toContain('unified-fab-core')
expect(css).not.toContain('.unified-fab:before')
expect(css).not.toContain('@keyframes fab-aura')
expect(css).not.toContain('@keyframes fab-pulse')
expect(css).toMatch(/\.unified-fab\{[^}]*background:var\(--accent\)/)
})
it('keeps the countdown close button but removes its orange circular background', () => {
expect(countdownPanel).toContain('<button type="button" aria-label="关闭" @click="closeDialog"><X/></button>')
expect(css).toMatch(/\.countdown-modal header button\{[^}]*background:transparent/)
expect(css).toMatch(/\.countdown-modal header button\{[^}]*border-radius:8px/)
})
it('removes inline create forms and opens the correct composer from the FAB', () => {
expect(app).not.toContain('class="quick"')
expect(mvpPanel).not.toContain('class="habit-create"')
expect(countdownPanel).not.toContain('class="countdown-add"')
expect(countdownPanel).not.toContain('class="fab countdown-fab')
expect(countdownPanel).not.toContain('@click="openCountdownComposer"')
expect(app).toContain("activeView==='habits'")
expect(app).toContain("activeView==='countdowns'")
expect(mvpPanel).toContain('openHabitComposer')
expect(countdownPanel).toContain('openCountdownComposer')
})
})
describe('desktop task detail disclosure', () => {
it('gives the task list the full remaining width until a task is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask)")
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
expect(app).toContain('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()')
expect(app).not.toContain('<div v-else class="paper">')
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 0;')
expect(css).toContain('.shell.detail-open{grid-template-columns:238px minmax(430px,1fr) 350px}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
expect(css).toContain('transition:grid-template-columns .22s ease')
})
})
describe('desktop sidebar collapse & folder scrollbar', () => {
it('keeps overlay scrims out of the desktop grid layout', () => {
expect(css).toContain('.scrim{display:none}')
expect(css).toContain('.scrim{position:fixed;z-index:35;')
})
it('lets the top menu collapse the desktop sidebar', () => {
expect(app).toContain('sidebar-collapsed')
expect(app).toContain('toggleSidebar')
expect(css).toContain('.shell.sidebar-collapsed{grid-template-columns:0 minmax(430px,1fr) 0}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
})
it('hides the folders scrollbar while staying scrollable', () => {
expect(css).toContain('scrollbar-width:none')
expect(css).toContain('.folders::-webkit-scrollbar{display:none}')
})
})
describe('sidebar information hierarchy', () => {
it('keeps daily navigation above lists and low-frequency management at the bottom', () => {
const primary = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(primary).toContain('收集箱')
expect(primary).toContain('今天')
expect(primary).toContain('最近 7 天')
expect(primary).toContain('习惯')
expect(primary).toContain('倒数日')
expect(primary).not.toContain('回收站')
expect(app).toContain('class="sidebar-management"')
expect(app).toMatch(/sidebar-management[\s\S]*?switchView\('trash'\)[\s\S]*?回收站[\s\S]*?switchView\('settings'\)[\s\S]*?设置/)
})
it('uses a compact brand row, restrained active marker and a single list creation trigger', () => {
expect(app).toContain('class="mini-icon list-create-trigger" aria-label="新建清单或文件夹"')
expect(app).not.toContain('aria-label="新建文件夹" @click="createFolder"')
expect(css).toContain('.brand-row{height:64px;')
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
expect(css).toContain('width:3px;')
})
it('collapses list management actions behind one explicit more button', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity')
expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity')
})
})
describe('sidebar layout', () => {
it('switches to the compact layout before the desktop grid can be clipped', () => {
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).not.toContain('@media(max-width:800px){.shell')
})
it('lets the list area flex and scroll inside the fixed sidebar', () => {
expect(css).toContain('.folders{flex:1;min-height:0;')
expect(css).toContain('overflow-y:auto')
})
it('keeps the mobile sidebar above the scrim so it remains clickable and scrollable', () => {
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
expect(css).toContain('.scrim{position:fixed;z-index:35;')
})
it('locks background scrolling while the mobile sidebar is open', () => {
expect(app).toContain("'mobile-sidebar-open': mobileSidebar")
expect(css).toContain('.shell.mobile-sidebar-open{overflow:hidden}')
expect(css).toContain('.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}')
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
expect(css).toContain('overscroll-behavior:contain')
})
it('uses valid sibling controls for sidebar rows', () => {
expect(app).toContain('class="list-row"')
expect(app).toContain('class="list-row-main"')
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('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', () => {
expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n 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)")
})
})