import { readFileSync } from 'node:fs' import { describe, expect, it, vi } from 'vitest' import { createCompletionPulse, completionExitDelay, shouldAnimateCompletionExit } 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') const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8') const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8') describe('unified task due display', () => { it('uses the shared display for overdue and ordinary parent task rows', () => { expect(app).toContain("import TaskDueDisplay from './components/TaskDueDisplay.vue'") expect(app.match(/ { const overdue = app.slice(app.indexOf('
t.completed).length') expect(overdue).toContain('{{node.subtasks.length}}') }) it('places every visible-list due display in a right tail before stable actions', () => { expect(app.match(/') expect(app).not.toContain('restoreTask(subtask)') expect(app).not.toContain('purgeTask(subtask)') expect(app).toContain('') expect(app).not.toContain('@click.stop="selectTask(subtask)">') expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}') }) it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => { expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:12px}') expect(css).toContain('.detail-form>label{grid-template-columns:80px 1fr;align-items:center}') expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}') }) it('keeps refresh neutral and at least 44px', () => { expect(css).toContain('.topbar-refresh{width:44px;height:44px;min-width:44px;border-radius:50%;color:var(--text-secondary)}') expect(css).not.toContain('.topbar-refresh:hover:not(:disabled){background:#fff7eb;color:var(--accent)}') }) }) describe('completion feedback motion', () => { it('delays removal only when a completed row will become hidden', () => { expect(shouldAnimateCompletionExit({ completing: true, showCompleted: false })).toBe(true) expect(shouldAnimateCompletionExit({ completing: true, showCompleted: true })).toBe(false) expect(shouldAnimateCompletionExit({ completing: false, showCompleted: false })).toBe(false) expect(completionExitDelay(false)).toBe(320) expect(completionExitDelay(true)).toBe(0) }) it('keeps completed task and habit rows mounted for the exit motion', () => { expect(app).toContain('completion-exiting') expect(mvpPanel).toContain('completion-exiting') expect(css).toContain('.task-row.completion-exiting,.habit-row.completion-exiting{') expect(css).toContain('@keyframes completion-slide-out') }) 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}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}') }) it('syncs the browser IANA timezone before loading user task data', () => { expect(app).toContain("Intl.DateTimeFormat().resolvedOptions().timeZone") expect(app).toContain("await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })") const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()')) expect(bootstrapBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()')) const authBlock = app.slice(app.indexOf('async function submitAuth()'), app.indexOf('async function loadTaskPages')) expect(authBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(authBlock.indexOf('await loadRestoredView()')) }) it('relies on the task PATCH for atomic due and recurrence synchronization', () => { const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges(')) expect(saveTaskBlock).not.toContain('/recurrences/') expect(saveTaskBlock).toContain('as Partial, false') const unifiedSaveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges('), app.indexOf('async function removeTask(')) expect(unifiedSaveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null') }) it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => { expect(css).toContain('@media(max-width:390px){.task-compose-sheet') expect(css).toContain('.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}') expect(css).toContain('.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}') expect(css).toContain('.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}') expect(css).toContain('.task-compose-sheet .app-sheet__footer>button{min-height:44px}') expect(css).toContain('.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}') }) it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => { vi.useFakeTimers() const active = new Set() 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('') 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(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('