885 lines
52 KiB
TypeScript
885 lines
52 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { createCompletionPulse } from './lib/completion-motion'
|
||
|
||
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('solid cream material system', () => {
|
||
it('defines three opaque cream surfaces, warm borders, restrained depth, and the radius scale', () => {
|
||
expect(css).toContain('--surface-canvas:#f6f0e5')
|
||
expect(css).toContain('--surface-base:#fff9ef')
|
||
expect(css).toContain('--surface-raised:#fffdf8')
|
||
expect(css).toContain('--border-cream:#e5d7c3')
|
||
expect(css).toContain('--highlight-inner:inset 0 1px 0 #fff')
|
||
expect(css).toContain('--radius-control:11px')
|
||
expect(css).toContain('--radius-card:14px')
|
||
expect(css).toContain('--radius-panel:20px')
|
||
})
|
||
|
||
it('removes blur, radial glow, and legacy liquid-glass tokens', () => {
|
||
expect(css).not.toContain('backdrop-filter')
|
||
expect(css).not.toContain('radial-gradient')
|
||
expect(css).not.toContain('--glass-surface')
|
||
expect(css).not.toContain('Warm Liquid Glass')
|
||
})
|
||
|
||
it('uses opaque cream surfaces for the shell, navigation, overlays, and feedback', () => {
|
||
expect(css).toContain('.shell{background:var(--surface-base)}')
|
||
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
||
expect(css).toContain('main{background:var(--surface-base)}')
|
||
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
||
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||
expect(css).toContain('.toast{background:#3b342c')
|
||
expect(css).toContain('.error-toast{background:var(--danger)')
|
||
expect(css).toContain('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
|
||
})
|
||
|
||
it('keeps compact continuous lists without per-row outer shadows', () => {
|
||
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{gap:0')
|
||
expect(css).toContain('.task-row,.habit-row,.countdown-row{background:var(--surface-raised);box-shadow:none')
|
||
expect(css).toContain('.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)')
|
||
})
|
||
|
||
it('keeps controls solid, focus visible, mobile targets safe, and motion reducible', () => {
|
||
expect(css).toContain('input,select,textarea,.search{background:var(--surface-raised)')
|
||
expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)')
|
||
expect(css).toContain('@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));')
|
||
expect(css).toContain('min-height:44px')
|
||
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
|
||
})
|
||
})
|
||
|
||
describe('completion feedback motion', () => {
|
||
it('animates only transient just-completed rows and respects reduced motion', () => {
|
||
expect(css).toContain('.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}')
|
||
expect(css).toContain('.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}')
|
||
expect(css).not.toContain('.task-row.done,.habit-row.done{animation:')
|
||
expect(css).toContain('@keyframes completion-row-settle')
|
||
expect(css).toContain('@keyframes completion-check-pop')
|
||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}')
|
||
})
|
||
|
||
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
|
||
vi.useFakeTimers()
|
||
const active = new Set<string>()
|
||
const pulse = createCompletionPulse((id) => active.add(id), (id) => active.delete(id), 420)
|
||
pulse('item-1')
|
||
pulse('item-1')
|
||
vi.advanceTimersByTime(16)
|
||
expect(active.has('item-1')).toBe(true)
|
||
vi.advanceTimersByTime(200)
|
||
pulse('item-1')
|
||
expect(active.has('item-1')).toBe(false)
|
||
vi.advanceTimersByTime(16)
|
||
expect(active.has('item-1')).toBe(true)
|
||
vi.advanceTimersByTime(220)
|
||
expect(active.has('item-1')).toBe(true)
|
||
vi.advanceTimersByTime(200)
|
||
expect(active.has('item-1')).toBe(false)
|
||
vi.useRealTimers()
|
||
})
|
||
})
|
||
|
||
describe('archived task-list disclosure', () => {
|
||
it('keeps one collapsed disclosure row even when the archive is empty', () => {
|
||
expect(app).toContain('const archivedListsExpanded = ref(false)')
|
||
expect(app).toContain('class="archived-lists-toggle"')
|
||
expect(app).toContain('已归档 {{ archivedLists.length }}')
|
||
expect(app).toContain(':aria-expanded="archivedLists.length > 0 && archivedListsExpanded"')
|
||
expect(app).toContain('aria-controls="archived-task-lists"')
|
||
expect(app).toContain(':disabled="archivedLists.length === 0"')
|
||
expect(app).toContain('id="archived-task-lists"')
|
||
})
|
||
|
||
it('renders compact archived rows with a restore and purge menu', () => {
|
||
expect(app).toContain('v-show="archivedListsExpanded"')
|
||
expect(app).toContain('class="list-row archived-row"')
|
||
expect(app).toContain('class="archived-row-menu-trigger"')
|
||
expect(app).toContain('恢复清单')
|
||
expect(app).toContain('永久删除清单')
|
||
expect(css).toContain('.archived-lists-toggle{min-height:44px;')
|
||
expect(css).toContain('.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;')
|
||
expect(css).toContain('.archived-row-actions{position:fixed;z-index:70;')
|
||
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
|
||
expect(css).toContain('.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;')
|
||
})
|
||
|
||
it('preserves the expanded state while restoring or deleting the last list', () => {
|
||
const restoreBlock = app.slice(app.indexOf('async function restoreList'), app.indexOf('function openPurgeList'))
|
||
const purgeBlock = app.slice(app.indexOf('async function confirmPurgeList'), app.indexOf('function toggleSidebarCreate'))
|
||
expect(restoreBlock).not.toContain('archivedListsExpanded.value = false')
|
||
expect(purgeBlock).not.toContain('archivedListsExpanded.value = false')
|
||
})
|
||
|
||
it('keeps archived and ordinary sidebar action menus mutually exclusive', () => {
|
||
const archivedActionBlock = app.slice(app.indexOf('function toggleArchivedListAction'), app.indexOf('function openPurgeList'))
|
||
const sidebarActionBlock = app.slice(app.indexOf('function openSidebarAction'), app.indexOf('function runSidebarCreate'))
|
||
expect(archivedActionBlock).toContain('closeSidebarAction()')
|
||
expect(sidebarActionBlock).toContain('closeArchivedListAction(false)')
|
||
})
|
||
|
||
it('closes the desktop archived menu from outside pointer input without replacing the mobile mask', () => {
|
||
expect(app).toContain("document.addEventListener('pointerdown', handleArchivedListOutsidePointer)")
|
||
expect(app).toContain("window.matchMedia('(min-width: 931px)').matches")
|
||
expect(app).toContain("target.closest('.archived-row-menu,.archived-row-actions')) closeArchivedListAction(false)")
|
||
expect(app).toContain('<Teleport to="body">')
|
||
expect(app).toContain('class="archived-action-mask" @click.self="closeArchivedListAction()"')
|
||
expect(app).toContain("document.addEventListener('scroll', handleArchivedListViewportChange, true)")
|
||
expect(app).toContain("window.addEventListener('resize', handleArchivedListViewportChange)")
|
||
expect(css).toContain('@media(max-width:930px){.archived-action-mask{display:block;position:fixed;')
|
||
})
|
||
|
||
it('restores archive menu focus after Escape and actions, with a fallback after row removal', () => {
|
||
expect(app).toContain('const archivedListsToggle = ref<HTMLButtonElement | null>(null)')
|
||
expect(app).toContain('let archivedListActionTrigger: HTMLElement | null = null')
|
||
expect(app).toContain("document.addEventListener('keydown', handleArchivedListEscape)")
|
||
expect(app).toContain('resolveArchivedMenuFocusTarget(archivedListActionTrigger, archivedListsToggle.value)')
|
||
expect(app).toContain('ref="archivedListsToggle"')
|
||
expect(app).toContain("@click=\"toggleArchivedListAction(list,$event.currentTarget)\"")
|
||
expect(app).toContain('purgeListTrigger = archivedListActionTrigger')
|
||
expect(app).toContain('focusArchivedListTrigger()')
|
||
})
|
||
|
||
it('disables archive disclosure motion for reduced-motion users', () => {
|
||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}')
|
||
})
|
||
})
|
||
|
||
describe('mobile sheet contract', () => {
|
||
it('uses shared roles for details, creation and secondary actions', () => {
|
||
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
|
||
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
|
||
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
|
||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
||
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
|
||
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
||
expect(css).toContain('.app-sheet__body{min-height:0;overflow-y:auto;')
|
||
expect(css).toContain('.app-sheet__footer{position:sticky;bottom:0;')
|
||
expect(css).toContain('padding-bottom:calc(16px + env(safe-area-inset-bottom))')
|
||
})
|
||
|
||
it('keeps the danger zone reserved for archived habit deletion', () => {
|
||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
|
||
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
|
||
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
|
||
})
|
||
})
|
||
|
||
describe('mobile list row language', () => {
|
||
it('uses one quiet bordered row surface for tasks, habits, and countdowns on mobile', () => {
|
||
expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{')
|
||
expect(css).toMatch(/@media\(max-width:930px\)\{\.task-row,\.habit-row,\.countdown-row\{[^}]*background:#fff;[^}]*border:1px solid var\(--line\);[^}]*border-radius:13px;[^}]*box-shadow:none/)
|
||
expect(css).toContain('.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}')
|
||
expect(css).toContain('.countdown-row{grid-template-columns:minmax(0,1fr) 72px;')
|
||
expect(css).not.toContain('grid-template-columns:44px minmax(0,1fr) 72px')
|
||
expect(css).not.toContain('grid-template-columns:40px minmax(0,1fr) 76px')
|
||
expect(css).toContain('.task-main strong,.habit-name,.countdown-main>b{font-size:14px;')
|
||
expect(css).toContain('.meta,.countdown-main>small,.countdown-state small{font-size:11px;')
|
||
expect(css).toContain('.habit-progress{font-size:16px}')
|
||
})
|
||
|
||
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
|
||
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
||
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
|
||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
|
||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
||
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
|
||
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
|
||
expect(mvpPanel).toContain('v-if="!selectedHabit.archived_at"')
|
||
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||
expect(mvpPanel).toContain('ref="habitDetailSheet"')
|
||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||
})
|
||
|
||
it('provides a minimal archived-habit viewing path', () => {
|
||
expect(mvpPanel).toContain("request('/habits?archived=true')")
|
||
expect(mvpPanel).toContain("{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length})` : '已归档' }}")
|
||
expect(mvpPanel).toContain('class="archived-toggle habit-archive-toggle"')
|
||
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
|
||
expect(css).toMatch(/@media\(max-width:930px\)\{\.habit-archive-toggle\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\)/)
|
||
expect(css).toMatch(/\.archived-habits\{[^}]*width:100%;[^}]*display:grid/)
|
||
expect(css).toMatch(/\.archived-habits>button\{[^}]*width:100%;[^}]*min-height:44px;[^}]*appearance:none;[^}]*grid-template-columns:minmax\(0,1fr\) auto;[^}]*background:var\(--surface-raised\);[^}]*border-bottom:1px solid var\(--border-cream\)/)
|
||
expect(css).toMatch(/\.archived-habits>button span\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/)
|
||
expect(css).toMatch(/\.archived-habits>button small\{[^}]*white-space:nowrap/)
|
||
})
|
||
|
||
it('reuses the habit composer for edits and patches only changed fields', () => {
|
||
expect(mvpPanel).toContain('const editingHabit = ref<Habit | null>(null)')
|
||
expect(mvpPanel).toContain('changedHabitFields(originalHabitForm.value, normalized)')
|
||
expect(mvpPanel).toContain("method: 'PATCH'")
|
||
expect(mvpPanel).toContain("habitComposerTitle")
|
||
expect(mvpPanel).toContain("habitFormError.value = reason instanceof Error")
|
||
})
|
||
})
|
||
|
||
describe('task and habit row decoration', () => {
|
||
it('removes the redundant habit refresh action', () => {
|
||
expect(mvpPanel).not.toContain('<RefreshCw />刷新')
|
||
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(1)
|
||
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
|
||
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(1)
|
||
})
|
||
|
||
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<string>())')
|
||
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('starts parent tasks collapsed when entering a task list or upcoming view', () => {
|
||
expect(app).toContain('function collapseLoadedTaskChildren()')
|
||
expect(app).toContain("else if (isTaskView(activeView.value)) {\n await loadAll()\n if (activeView.value === 'tasks' || activeView.value === 'upcoming') collapseLoadedTaskChildren()")
|
||
expect(app).toContain("else {\n await loadAll()\n if (view === 'tasks' || view === 'upcoming') collapseLoadedTaskChildren()")
|
||
expect(app).toContain('tasks.value.filter((task) => task.subtasks?.length).map((task) => 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('reconciles concurrent completion mutations without suppressing their feedback', () => {
|
||
const toggleBlock = app.slice(app.indexOf('async function toggle(task: Task)'), app.indexOf('function isInteractiveTarget'))
|
||
expect(toggleBlock).toContain('const completing = !task.completed')
|
||
expect(toggleBlock).toContain('await taskMutationReconciler.run(')
|
||
expect(toggleBlock).toContain("() => toast(completing ? '完成啦' : '已重新打开')")
|
||
expect(toggleBlock).toContain('applyTaskUpdate(task, updated)')
|
||
expect(toggleBlock).not.toContain("beginLatestRequest('tasks')")
|
||
})
|
||
|
||
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('function currentHabitForm'))
|
||
expect(toggleBlock).toContain('setLocalHabitValue(h, next)')
|
||
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
|
||
expect(toggleBlock).not.toContain('await loadHabits()')
|
||
})
|
||
|
||
it('places numeric habit progress at the far right instead of under the title', () => {
|
||
expect(mvpPanel.match(/<small v-if="habitProgressText\(h\)" class="habit-progress">/g)?.length).toBe(2)
|
||
expect(mvpPanel).not.toContain('<span><span class="habit-name">{{ h.name }}</span><small')
|
||
expect(css).toContain('.habit-progress{margin-left:auto;')
|
||
})
|
||
|
||
it('keeps Today focused and adds the weekly grid only to the full habits view', () => {
|
||
expect(mvpPanel.match(/class="habit-week"/g)?.length).toBe(1)
|
||
expect(mvpPanel).toContain('v-for="cell in h.cells"')
|
||
expect(mvpPanel).toContain('class="habit-week-cell"')
|
||
expect(mvpPanel).toContain('class="habit-week-day"')
|
||
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?class="habit-week"[\s\S]*?<!-- 完整习惯列表 -->/)
|
||
expect(mvpPanel).not.toMatch(/today-habit-list[\s\S]*?habit-drag-handle[\s\S]*?<!-- 完整习惯列表 -->/)
|
||
})
|
||
|
||
it('uses numeric progress as the main feedback and omits it for boolean habits', () => {
|
||
expect(mvpPanel.match(/<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2)
|
||
expect(mvpPanel.match(/:value="habitProgressValue\(h\)" :max="habitProgressMax\(h\)"/g)?.length).toBe(2)
|
||
expect(mvpPanel.match(/:aria-label="`\$\{h.name\}进度:\$\{habitProgressText\(h\)\}`"/g)?.length).toBe(2)
|
||
expect(css).toContain('.habit-progress{margin-left:auto;')
|
||
expect(css).toContain('font-size:16px')
|
||
expect(css).toContain('.habit-progress-bar{grid-column:1/-1;')
|
||
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:linear-gradient')
|
||
})
|
||
|
||
it('restores the current page and list from local storage', () => {
|
||
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('uses one positive completed-item preference across task and habit views', () => {
|
||
expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)")
|
||
expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)")
|
||
expect(app).toContain(':show-completed="showCompleted"')
|
||
expect(app).toContain('@update:show-completed="showCompleted = $event"')
|
||
expect(mvpPanel).toContain('showCompleted: boolean')
|
||
expect(mvpPanel).toContain("'update:showCompleted': [value: boolean]")
|
||
expect(mvpPanel).not.toContain('HIDE_COMPLETED_HABITS_STORAGE_KEY')
|
||
expect(mvpPanel).not.toContain('dodo.hide-completed-habits')
|
||
})
|
||
|
||
it('shows one positive switch before Today content and no switch inside today habits', () => {
|
||
expect(app).toContain('class="list-toolbar today-completed-toolbar"')
|
||
expect(app.indexOf('class="list-toolbar today-completed-toolbar"')).toBeLessThan(app.indexOf('class="today-board"'))
|
||
expect(app).toContain('<input v-model="showCompleted" type="checkbox"> 显示已完成')
|
||
const todayHabits = mvpPanel.slice(mvpPanel.indexOf('<!-- 今日习惯'), mvpPanel.indexOf('<!-- 完整习惯列表'))
|
||
expect(todayHabits).not.toContain('type="checkbox"')
|
||
expect(todayHabits).not.toContain('显示已完成')
|
||
expect(todayHabits).not.toContain('隐藏已完成')
|
||
})
|
||
|
||
it('filters habits with the shared preference and disables sorting while completed items are hidden', () => {
|
||
expect(mvpPanel).toContain('const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value')
|
||
expect(mvpPanel).toContain('const visibleHabits = computed(() => props.showCompleted ? habits.value')
|
||
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
|
||
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
|
||
expect(mvpPanel).toContain(':disabled="!showCompleted"')
|
||
expect(mvpPanel).toContain('if (busy.value || !props.showCompleted) return')
|
||
expect(css).toContain('.habit-toolbar{')
|
||
})
|
||
|
||
it('distinguishes hidden completed items from true and search empty states', () => {
|
||
expect(app).toContain('const hiddenCompletedTaskCount = ref(0)')
|
||
expect(app).toContain("query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静'")
|
||
expect(mvpPanel).toContain("!showCompleted && todayHabits.length ? '已完成的习惯已隐藏。'")
|
||
expect(mvpPanel).toContain("!showCompleted && habits.length ? '已完成的习惯已隐藏。'")
|
||
})
|
||
|
||
it('shows unfinished overdue tasks as a separate list inside Today', () => {
|
||
expect(app).toContain('const overdueTasks = ref<Task[]>([])')
|
||
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('任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}')
|
||
expect(app).toContain('习惯 {{ todayHabitProgress }}')
|
||
expect(app).not.toContain('const todayProgressText = computed')
|
||
expect(app).not.toContain('const todayTaskProgressHint = computed')
|
||
expect(app).not.toContain('const todayHabitProgressHint = computed')
|
||
expect(app).not.toContain('<p v-if="activeView===\'today\'">')
|
||
expect(app).not.toContain('class="paper today-summary"')
|
||
expect(app).not.toContain('today-summary-grid')
|
||
expect(app).toContain("scrollTodaySection('today-tasks')")
|
||
expect(app).toContain("scrollTodaySection('today-habits')")
|
||
expect(app).not.toContain('<div class="today-stat"')
|
||
expect(app).toContain('@summary="updateTodayHabitSummary"')
|
||
expect(mvpPanel).toContain("summary: [value: { total: number; completed: number }]")
|
||
expect(mvpPanel).toContain('const todayHabitSummary = computed')
|
||
expect(mvpPanel).toContain("emit('summary', value)")
|
||
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-track{')
|
||
expect(css).toContain('.today-track-fill{')
|
||
expect(css).toContain('.today-habit-track .today-track-fill{')
|
||
expect(css).not.toContain('.today-stat{')
|
||
expect(css).not.toContain('.today-summary{')
|
||
expect(css).not.toContain('.today-summary-grid{')
|
||
expect(css).not.toContain('.today-track-head small{')
|
||
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('uses a gentle completion treatment instead of a discarded-task strike', () => {
|
||
expect(css).toContain('.habit-row.done .habit-name{color:#756c61;')
|
||
expect(css).toContain('text-decoration-color:#d8c8bb')
|
||
expect(css).toContain('text-decoration-thickness:1px')
|
||
expect(css).not.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('approved habit safety and U2 title hierarchy', () => {
|
||
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
|
||
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||
expect(mvpPanel).toContain('<h2>修改密码</h2>')
|
||
expect(mvpPanel).toContain('<h2>登录会话</h2>')
|
||
expect(mvpPanel).toContain('<h2>最近活动</h2>')
|
||
expect(css).toContain('.tool-card>h2{')
|
||
})
|
||
|
||
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
||
expect(app).toContain('const modalError = ref')
|
||
expect(app).toContain('role="alert" class="field-error"')
|
||
expect(app).toContain('normalizeRequiredName')
|
||
expect(mvpPanel).toContain('habitErrors.name')
|
||
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
|
||
expect(mvpPanel).toContain('const habitFormInvalid = computed')
|
||
expect(mvpPanel).toContain(':disabled="busy || habitFormInvalid"')
|
||
expect(mvpPanel).toContain('请修正表单中的错误后再保存')
|
||
})
|
||
|
||
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
|
||
expect(css).toContain('@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
|
||
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
|
||
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
|
||
})
|
||
|
||
it('disables illegal habit writes and keeps optimistic rollback', () => {
|
||
expect(mvpPanel).toContain('habitAction(h)')
|
||
expect(mvpPanel).toContain(':disabled="!habitAction(h).writable"')
|
||
expect(mvpPanel).toContain(':aria-disabled="!habitAction(h).writable"')
|
||
expect(mvpPanel).toContain('setLocalHabitValue(h, previous)')
|
||
expect(mvpPanel).toContain('formatHabitApiError')
|
||
})
|
||
})
|
||
|
||
describe('settings data tools', () => {
|
||
it('keeps backup export and restore but removes the standalone import tool', () => {
|
||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||
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('<h3>导入</h3>')
|
||
expect(mvpPanel).not.toContain("request('/import/ticktick")
|
||
expect(mvpPanel).not.toContain('importFile')
|
||
expect(mvpPanel).not.toContain('importPreview')
|
||
})
|
||
})
|
||
|
||
describe('task detail layout', () => {
|
||
it('keeps long subtask text inside the detail panel', () => {
|
||
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);')
|
||
expect(css).toContain('.detail-form>*{min-width:0}')
|
||
expect(css).toContain('.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;')
|
||
})
|
||
|
||
it('keeps the list, due datetime and repeat controls equally compact', () => {
|
||
expect(app).toContain('<label class="task-detail-due-row">截止时间')
|
||
expect(app).toContain('class="task-detail-due-input task-detail-field-input"')
|
||
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
||
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
|
||
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
|
||
})
|
||
})
|
||
|
||
describe('unified floating add interaction', () => {
|
||
it('uses compact date and time chips instead of large visible mobile inputs', () => {
|
||
expect(app).toContain("composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''")
|
||
expect(app).toContain('const composeDueLabel = computed')
|
||
expect(app).toContain('ref="composeDateButton"')
|
||
expect(app).toContain('aria-haspopup="dialog" :aria-expanded="composeCalendarOpen"')
|
||
expect(app).toContain('class="task-compose-date-chip"')
|
||
expect(app).toContain('@click="composeCalendarOpen=true"')
|
||
expect(app).toContain('<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt"')
|
||
expect(app).toContain('<span>{{ composeDueLabel }}</span>')
|
||
expect(app).toContain('v-if="composeDueAt" class="task-compose-date-clear"')
|
||
expect(app).toContain('v-if="composeDueAt && !composeHasTime"')
|
||
expect(app).toContain('v-else-if="composeDueAt" class="task-compose-time-chip"')
|
||
expect(app).toContain('function clearComposeDueDate()')
|
||
expect(css).toContain('.task-compose-date-control{position:relative;display:flex;align-items:center;min-width:0}')
|
||
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important')
|
||
expect(css).toContain('.task-compose-date-chip{position:relative;display:inline-flex!important;grid-template-columns:none!important;align-items:center;flex-direction:row!important;gap:7px!important;width:auto;min-height:44px')
|
||
expect(css).not.toContain('@media(max-width:480px){.task-compose-due-row')
|
||
expect(css).not.toContain('.task-compose-native-picker{')
|
||
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('重复<select v-model="composeRepeat"')
|
||
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
||
expect(app).toContain('rrule,')
|
||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||
expect(app).toContain('<option value="custom">自定义…</option>')
|
||
expect(app).toContain('class="repeat-custom-fields"')
|
||
expect(app).toContain('每隔')
|
||
expect(app).toContain('重复日期')
|
||
expect(app).toContain('结束方式')
|
||
expect(app).toContain('重复次数')
|
||
})
|
||
|
||
it('preloads countdowns through the shared cache after authentication', () => {
|
||
expect(app).toContain('void preloadCountdowns()')
|
||
expect(app).toContain('await loadCountdownCache(async () =>')
|
||
expect(app).toContain("Promise.all([api('/countdowns'), api('/countdowns?archived=true')])")
|
||
})
|
||
|
||
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 task detail disclosure', () => {
|
||
it('gives the task list the full remaining width until a task is selected', () => {
|
||
expect(app).toContain("'detail-open': Boolean(selectedTask)")
|
||
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
|
||
expect(app).toContain('@click="closeTaskDetail"')
|
||
expect(app).toContain('function closeTaskDetail()')
|
||
expect(app).not.toContain('<div v-else class="paper">')
|
||
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 0;')
|
||
expect(css).toContain('.shell.detail-open{grid-template-columns:238px minmax(430px,1fr) 350px}')
|
||
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
|
||
expect(css).toContain('transition:grid-template-columns .22s ease')
|
||
})
|
||
})
|
||
|
||
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) 0}')
|
||
expect(css).toContain('.shell.sidebar-collapsed.detail-open{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 information hierarchy', () => {
|
||
it('keeps daily navigation above lists and low-frequency management at the bottom', () => {
|
||
const primary = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
|
||
expect(primary).toContain('收集箱')
|
||
expect(primary).toContain('今天')
|
||
expect(primary).toContain('最近 7 天')
|
||
expect(primary).toContain('习惯')
|
||
expect(primary).toContain('倒数日')
|
||
expect(primary).not.toContain('回收站')
|
||
expect(app).toContain('class="sidebar-management"')
|
||
expect(app).toMatch(/sidebar-management[\s\S]*?switchView\('trash'\)[\s\S]*?回收站[\s\S]*?switchView\('settings'\)[\s\S]*?设置/)
|
||
})
|
||
|
||
it('uses a compact brand row, restrained active marker and a single list creation trigger', () => {
|
||
expect(app).toContain('class="mini-icon list-create-trigger" aria-label="新建清单或文件夹"')
|
||
expect(app).not.toContain('aria-label="新建文件夹" @click="createFolder"')
|
||
expect(css).toContain('.brand-row{height:64px;')
|
||
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
|
||
expect(css).toContain('width:3px;')
|
||
})
|
||
|
||
it('collapses list management actions behind one explicit more button', () => {
|
||
expect(app).toContain('aria-label="打开文件夹操作"')
|
||
expect(app).toContain('aria-label="打开清单操作"')
|
||
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
|
||
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
|
||
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
|
||
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
|
||
expect(app).not.toContain('aria-label="重命名文件夹" @click="renameEntity')
|
||
expect(app).not.toContain('aria-label="重命名清单" @click="renameEntity')
|
||
})
|
||
})
|
||
|
||
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('locks background scrolling while the mobile sidebar is open', () => {
|
||
expect(app).toContain("'mobile-sidebar-open': mobileSidebar")
|
||
expect(css).toContain('.shell.mobile-sidebar-open{overflow:hidden}')
|
||
expect(css).toContain('.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}')
|
||
expect(css).toContain('.sidebar{position:fixed;z-index:50;')
|
||
expect(css).toContain('overscroll-behavior:contain')
|
||
})
|
||
|
||
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('supports dragging movable lists into folders, out to My Lists, and within one scope', () => {
|
||
expect(app).toContain('class="section-title list-root-drop"')
|
||
expect(app).toContain(':data-folder-id="folder.id"')
|
||
expect(app).toContain(':data-list-id="list.id"')
|
||
expect(app).toContain('class="list-drag-handle"')
|
||
expect(app).toContain("api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })")
|
||
expect(app).toContain("} else {\n await api('/lists/reorder'")
|
||
expect(app).toContain('lists.value = previous')
|
||
expect(app).toContain('expandedFolders.value = new Set(expandedFolders.value).add(folderId)')
|
||
expect(app).not.toContain('list.is_inbox" class="list-drag-handle"')
|
||
})
|
||
|
||
it('uses the handle-only touch contract and exposes same-scope move controls', () => {
|
||
expect(app).not.toContain('@pointerdown="startListLongPress(list, $event)"')
|
||
expect(app).not.toContain('function startListLongPress')
|
||
expect(app).not.toContain('listLongPressTimer')
|
||
expect(app).toContain('@pointerdown.stop="startListHandlePress(list,$event)"')
|
||
expect(app).toContain('listHandlePending = { id: list.id, pointer }')
|
||
expect(app).toContain('window.setTimeout(() => beginListDrag(list, pointer), 450)')
|
||
expect(app).toContain('@pointermove.stop="moveListHandle(list,$event)"')
|
||
expect(app).toContain("if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId)")
|
||
expect(app).toContain('hasExceededLongPressMovement(')
|
||
expect(app).toContain('clearListHandlePress()')
|
||
expect(app).toContain('moveListDrag(list, event)')
|
||
expect(app).toContain('@pointerup.stop="finishListDrag(list,$event)"')
|
||
expect(app).toContain('@pointercancel.stop="cancelListDrag"')
|
||
expect(app).toContain('listHandlePending = undefined')
|
||
expect(app).toContain('aria-label="上移清单"')
|
||
expect(app).toContain('aria-label="下移清单"')
|
||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'up')")
|
||
expect(app).toContain("moveListWithinScope(sidebarAction.item as TaskList, 'down')")
|
||
expect(app).toContain('aria-label="移动到文件夹"')
|
||
expect(app).toContain('role="menu"')
|
||
expect(app).toContain('role="menuitem"')
|
||
expect(app).toContain('移出文件夹')
|
||
})
|
||
|
||
it('shows drag lift, folder highlighting, and insertion targets', () => {
|
||
expect(css).toContain('.list-row.list-dragging{')
|
||
expect(css).toContain('.folder-row.list-drop-target{')
|
||
expect(css).toContain('.list-root-drop.list-drop-target{')
|
||
expect(css).toContain('.list-row.list-reorder-target{')
|
||
expect(css).toContain('.list-drag-handle{width:44px;min-width:44px;height:44px;')
|
||
expect(css).toContain('.list-row{touch-action:pan-y}')
|
||
expect(css).not.toContain('.list-drag-handle{display:none}')
|
||
expect(css).not.toContain('.list-row.list-dragging{touch-action:none}')
|
||
})
|
||
|
||
it('only restores archived lists from the explicit restore menu action', () => {
|
||
expect(app).toContain('class="archived-row-label"')
|
||
expect(app).not.toContain(':aria-label="`恢复清单:${list.name}`" @click="restoreList(list)"')
|
||
expect(app).toContain('@click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单')
|
||
})
|
||
|
||
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 loadRestoredView()')
|
||
})
|
||
|
||
it('styles archived rows through the compact disclosure structure', () => {
|
||
expect(css).toContain('.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;')
|
||
})
|
||
|
||
it('offers permanent deletion only inside archived list menus', () => {
|
||
expect(app).toContain('@click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单')
|
||
expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })")
|
||
expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/)
|
||
})
|
||
|
||
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
||
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
||
expect(app).toContain('ref="purgeCancelButton"')
|
||
expect(app).toContain('purgeCancelButton.value?.focus()')
|
||
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
|
||
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
|
||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||
expect(app).toContain('role="alert" class="purge-list-error"')
|
||
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
|
||
})
|
||
|
||
it('removes a purged row and persists inbox navigation after archiving the current list', () => {
|
||
expect(app).toContain('archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)')
|
||
expect(app).toContain("const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id")
|
||
expect(app).toContain('if (wasCurrentList)')
|
||
expect(app).toContain("await switchView('tasks', inboxId)")
|
||
expect(app).toContain('selectedTask.value = null')
|
||
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")
|
||
})
|
||
})
|