287 lines
14 KiB
TypeScript
287 lines
14 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('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(2)
|
|
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(/<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('can hide completed habits in both Today and full habit views', () => {
|
|
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('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<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('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('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 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(/<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 loadAll()')
|
|
})
|
|
|
|
it('styles archived rows through the new list-row-main structure', () => {
|
|
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}')
|
|
})
|
|
})
|