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('warm Liquid Glass styling', () => { it('uses shared warm glass and radius tokens with Safari support', () => { expect(css).toContain('--radius-panel:28px') expect(css).toContain('--glass-surface-strong:rgba(255,253,248,.84)') expect(css).toContain('backdrop-filter:blur(26px) saturate(1.45) brightness(1.04)') expect(css).toContain('-webkit-backdrop-filter:blur(26px) saturate(1.45) brightness(1.04)') }) it('keeps list rows solid while floating the mobile navigation', () => { expect(css).toContain('.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));') expect(css).toContain('.bottom button.active{background:rgba(241,90,41,.1)}') expect(css).toContain('@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;') expect(css).not.toMatch(/\.task-row\{[^}]*backdrop-filter/) expect(css).not.toMatch(/\.habit-row\{[^}]*backdrop-filter/) expect(css).not.toMatch(/\.countdown-row\{[^}]*backdrop-filter/) }) }) 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('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('