Files
dodo/frontend/src/style.test.ts
T
bboysoul cc9252cea1
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 6m48s
refactor: clarify sidebar navigation hierarchy
2026-09-08 20:59:49 +08:00

568 lines
32 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
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('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 destructive actions in a separate bottom danger zone', () => {
expect(mvpPanel).toContain('class="app-sheet__danger"')
expect(mvpPanel).toMatch(/app-sheet__danger[\s\S]*?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('keeps destructive habit actions in a detail sheet instead of the list row', () => {
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="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain('永久删除')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
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()")
})
})
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', () => {
expect(app).toContain('function collapseLoadedTaskChildren()')
expect(app).toContain("else if (isTaskView(activeView.value)) {\n await loadAll()\n if (activeView.value === 'tasks') collapseLoadedTaskChildren()")
expect(app).toContain("else {\n await loadAll()\n if (view === 'tasks') 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('refreshes the current task data after toggling completion', () => {
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
expect(toggleBlock).toContain('await patchTask(task, { completed: !task.completed })')
expect(toggleBlock).toContain('await loadTasksPage()')
expect(toggleBlock).toContain("if (activeView.value === 'today')")
expect(toggleBlock).toContain('await loadOverdueTasks()')
expect(toggleBlock).toContain('void loadTodayTaskSummary()')
})
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('async function addHabit'))
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('restores the completed-item visibility choices from local storage', () => {
expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
expect(mvpPanel).toContain("readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false)")
expect(mvpPanel).toContain("writeStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, value)")
})
it('can hide completed habits in both Today and full habit views', () => {
expect(mvpPanel).toContain("const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))")
expect(mvpPanel).toContain('const visibleTodayHabits = computed')
expect(mvpPanel).toContain('const visibleHabits = computed')
expect(mvpPanel).toContain('v-model="hideCompletedHabits"')
expect(mvpPanel).toContain('隐藏已完成')
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
expect(css).toContain('.habit-toolbar{')
})
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('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h3>数据导出与恢复</h3>')
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('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('class="task-compose-date-chip"')
expect(app).toContain('class="task-compose-native-picker"')
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-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}')
expect(css).toContain('.task-compose-native-picker{position:absolute;inset:0;opacity:0;cursor:pointer')
expect(css).not.toContain('@media(max-width:480px){.task-compose-due-row')
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 after authentication', () => {
expect(app).toContain('void preloadCountdowns()')
expect(app).toContain("const active = await api('/countdowns')")
expect(app).toContain("const archived = await api('/countdowns?archived=true')")
expect(app).toContain('writeCountdownCache(active ?? [], archived ?? [])')
})
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;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('only restores archived lists from the explicit restore action', () => {
expect(app).toContain('class="list-row-main archived-row-label"')
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
expect(app).toContain('aria-label="恢复清单" @click="restoreList(list)"')
})
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 new list-row-main structure', () => {
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}')
})
})