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(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(/ { 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('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 loadAll()') }) it('styles archived rows through the new list-row-main structure', () => { expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}') }) })