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('更多') 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('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() 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('