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(mvpPanel).toContain('const hideCompletedHabits = ref(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('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('重复