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('更多') expect(app).toContain("设置") expect(app).toContain("@click=\"switchView('settings')\"") expect(app).toContain('设置') }) 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('
') 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('task and habit row decoration', () => { it('removes the redundant habit refresh action', () => { expect(mvpPanel).not.toContain('刷新') 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(2) 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(2) }) 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())') 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('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('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(//g)?.length).toBe(2) expect(mvpPanel).not.toContain('{{ h.name }} { 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([])') 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('还有 ${Math.max(todayHabitTotal.value - todayHabitCompleted.value, 0)} 项未完成') expect(app).toContain('任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}') expect(app).toContain('习惯 {{ todayHabitProgress }}') expect(app).toContain("scrollTodaySection('today-tasks')") expect(app).toContain("scrollTodaySection('today-habits')") expect(app).not.toContain('
{ 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('defaults every new-task composer to today', () => { expect(app).toContain('composeDueAt.value = defaultTaskDueAt()') expect(app).toContain('v-model="composeDueAt" type="date"') expect(app).not.toContain('class="time-toggle"') expect(app).not.toContain('v-model="composeHasTime" type="checkbox"') expect(app).toContain('class="task-compose-due-row"') expect(app).toContain('class="task-compose-time-add"') expect(app).toContain('class="task-compose-time-remove"') expect(css).toContain('.task-compose-due-row{display:grid;grid-template-columns:minmax(0,1fr) auto;') 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('重复