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('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('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(/ { 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('todayOpenTasks') expect(app).toContain('todayCompletedTasks') 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-summary-grid{') 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('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('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('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('重复