([])')
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('')
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('
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(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('strikes through completed habit names', () => {
expect(css).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('数据导出与恢复 ')
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('导入 ')
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('{{ composeDueLabel }} ')
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('重复自定义…')
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('')
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(' ')
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 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) 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 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('uses valid sibling controls for sidebar rows', () => {
expect(app).toContain('class="list-row"')
expect(app).toContain('class="list-row-main"')
expect(app).not.toMatch(/]*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}')
})
})