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') const appSheet = readFileSync('src/components/AppSheet.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).not.toContain('task-detail-trigger') expect(app).toContain('') }) it('uses a right-aligned single-line tail without empty slots', () => { expect(css).toContain('.task-tail{flex:0 1 auto;min-width:0;max-width:') expect(css).toContain('text-align:right;white-space:nowrap') expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}') expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}') expect(css).toContain('.plain-list .task-due--timed .task-due__text{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.2}') expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}') expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;') expect(css).toContain('.task-check{width:44px;height:44px;') expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;') expect(css).toMatch(/\.restore\{[^}]*min-height:44px/) }) it('overrides the legacy timed mobile presentation inside every plain list', () => { expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}') expect(css).toContain('.plain-list .task-due--timed .task-due__text{display:block;') expect(css).toContain('.plain-list .task-due--timed .task-due__separator{display:inline}') }) it('scopes content metadata and keeps date semantics without a visible calendar icon', () => { expect(css).toContain('.task-due--overdue{color:var(--danger)}') expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}') expect(css).toContain('.task-main{border:0;background:transparent;flex:1;min-width:0;') expect(css).toContain('.meta-item{display:flex;align-items:center;gap:3px}') expect(css).not.toContain('.meta span{display:flex;') expect(taskDueDisplay).not.toContain('CalendarDays') expect(taskDueDisplay).toContain(' { it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => { expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;border-radius:10px;font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}') expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}') expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:16px;gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}') expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}') expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid #ddd0c0;background:#fffaf3;color:#51483f;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(83,62,42,.06)}') expect(css).toContain('.danger-button{border:1px solid #e2b7ac;background:#fff8f5;color:#b83c2c;box-shadow:inset 0 1px 0 #fff,0 2px 5px rgba(105,48,34,.05)}') }) it('provides restrained interaction, focus, and disabled states without resizing buttons', () => { expect(css).toContain(':is(.primary,.primary-small):hover:not(:disabled){background:#dd4b1e;border-color:#dd4b1e}') expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button):hover:not(:disabled):not(.disabled){background:#f8eddf;border-color:#d5c5b2}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:translateY(1px) scale(.985)}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):focus-visible{outline:3px solid rgba(241,90,41,.24);outline-offset:3px}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):disabled,.file-button.disabled{background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;cursor:not-allowed;opacity:1}') expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button:disabled,.backup-preflight .danger-button:disabled,.password-form .primary-small:disabled){background:#ebe2d8;border-color:#ded3c7;color:#aaa095;box-shadow:none;opacity:1}') }) it('uses a real disabled file-picker button and keeps its input out of layout', () => { expect(mvpPanel).toContain('function openRestoreFilePicker()') expect(mvpPanel).toContain('') expect(mvpPanel).toContain(' { expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;') expect(css).toContain('width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent)') expect(css).toContain('.calendar-picker__header button{width:44px;height:44px}') expect(css).toContain('.calendar-picker__grid button{min-height:44px}') expect(css).not.toContain('button:not(') }) }) describe('mobile navigation styles', () => { it('uses five direct mobile destinations and keeps Settings in the sidebar', () => { expect(app).not.toContain('aria-controls="mobile-more-menu"') expect(app).not.toContain('更多') expect(app).toContain("备忘录") expect(app).toContain("日历") expect(app).not.toContain("@click=\"switchView('settings')\">设置") }) it('marks only exact mobile destinations active and exposes aria-current only there', () => { for (const view of ['today', 'habits', 'countdowns', 'memos', 'calendar']) { expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`) } expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'") }) it('has no unreachable mobile More state, template, or styles', () => { expect(app).not.toContain('mobileMore') expect(app).not.toContain('mobile-more-menu') expect(app).not.toContain('more-mask') expect(css).not.toContain('.more-mask') expect(css).not.toContain('.more-sheet') expect(css).not.toContain('.task-compose-mask') expect(css).not.toContain('.habit-detail-mask') expect(css).not.toContain('.countdown-detail-mask') expect(css).not.toContain('.countdown-modal-mask') expect(css).not.toContain('.modal-mask') expect(css).not.toContain('.modal-box') expect(css).not.toContain('.countdown-compose-enter') }) 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('approved Settings 01 paper ledger', () => { it('moves Settings identity into the body and suppresses the duplicate shell title', () => { expect(app).not.toContain("'settings-main':activeView==='settings'") expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"") expect(app).toContain("v-if=\"!['today','tasks','upcoming','habits','settings'].includes(activeView)\" class=\"topbar-title\"") expect(app).not.toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"') expect(app).not.toContain('aria-label="刷新当前页面"') expect(mvpPanel).toContain('

设置

管理数据、账户与登录设备

') expect(mvpPanel).toContain('

数据与恢复

完整备份') expect(mvpPanel).toContain("

登录设备

{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}") expect(mvpPanel).toContain('

活动记录

最近操作') }) it('uses the selected continuous-paper geometry on desktop and mobile', () => { expect(css).toContain('main:has(>.mvp-view .settings-sections){background:#fffdf8}') expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}') expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;') expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}') expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}') expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;') expect(css).toContain('.settings-topbar{margin-bottom:0}') expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}') expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}') expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}') expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}') expect(css).toContain('.settings-row{min-height:64px}') }) it('keeps controls and password errors aligned with the approved form contract', () => { expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){border-radius:10px}') expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff;box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}') expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}') expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}') expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}') expect(mvpPanel).toContain("const sessionsState = ref<'loading' | 'success' | 'error'>('loading')") expect(mvpPanel).toContain("const auditState = ref<'loading' | 'success' | 'error'>('loading')") expect(mvpPanel).toContain('await Promise.allSettled([') expect(mvpPanel).toContain("v-if=\"sessionsState==='loading'\"") expect(mvpPanel).toContain("v-else-if=\"sessionsState==='error'\"") expect(mvpPanel).toContain("v-if=\"auditState==='loading'\"") expect(mvpPanel).toContain("v-else-if=\"auditState==='error'\"") expect(mvpPanel).not.toContain("'/sessions').catch(() => [])") expect(css).toContain('@media(max-width:720px){') expect(css).toContain('.password-form.settings-form{grid-template-columns:1fr}') }) }) describe('settings sessions and audit activity', () => { it('offers a current-device logout action that returns the app to login', () => { expect(mvpPanel).toContain("import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'") expect(mvpPanel).toContain("logout: []") expect(mvpPanel).toContain("await request('/auth/logout', { method: 'POST' })") expect(mvpPanel).toContain("emit('logout')") expect(mvpPanel).toContain('class="settings-row settings-logout-row"') expect(mvpPanel).toContain('退出登录仅退出当前设备,不影响其他设备') expect(mvpPanel).toContain('class="danger-text settings-logout-button"') expect(mvpPanel).toContain("{{ logoutBusy ? '正在退出…' : '退出登录' }}") expect(app).toContain('@logout="completeLogout"') expect(app).toContain('function completeLogout()') expect(css).toContain('.settings-logout-button{min-width:88px;display:inline-flex;align-items:center;justify-content:center;gap:6px}') }) it('renders readable session metadata, local time, and an accessible revoke action', () => { expect(mvpPanel).toContain('class="session-copy"') expect(mvpPanel).toContain('class="session-title"') expect(mvpPanel).toContain('class="session-meta"') expect(mvpPanel).toContain('class="session-device"') expect(mvpPanel).toContain(':datetime="s.last_seen_at ?? s.created_at"') expect(mvpPanel).toContain(':aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`"') expect(mvpPanel).toContain('class="danger-text session-revoke"') expect(mvpPanel).toContain('class="session-card-actions"') expect(mvpPanel).toContain('撤销其他所有会话') expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })") expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')") expect(css).toContain('.session-card-actions{') }) it('does not expose the raw user agent in session DOM attributes', () => { expect(mvpPanel).not.toContain(':title="s.user_agent || undefined"') expect(mvpPanel).not.toMatch(/]*s\.user_agent[^>]*>/) expect(mvpPanel).toContain('{{ formatUserAgent(s.user_agent) }}') }) it('renders localized audit copy and semantic local time', () => { expect(mvpPanel).toContain('class="audit-copy"') expect(mvpPanel).toContain('class="audit-action"') expect(mvpPanel).toContain("formatAuditAction(row.action ?? row.event)") expect(mvpPanel).toContain('formatAuditEntity(row.entity_type)') expect(mvpPanel).toContain(':datetime="row.created_at ?? row.timestamp"') }) it('keeps revoke and all existing drag handles at 44px without enlarging icons', () => { expect(css).toContain('.drag-handle{width:44px;min-width:44px;height:44px;flex:0 0 44px;') expect(css).toContain('.drag-handle svg{width:17px;height:17px}') expect(css).not.toContain('.drag-handle{width:36px;flex-basis:36px}') expect(css).toContain('.session-copy{min-width:0;flex:1;') expect(css).toContain('.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;') }) }) describe('solid cream material system', () => { it('defines the approved opaque cream surfaces, text colors, warm border, and status colors', () => { expect(css).toContain('--surface-canvas:#f3ecdf') expect(css).toContain('--surface-base:#fdfaf3') expect(css).toContain('--surface-raised:#fffdf8') expect(css).toContain('--text-primary:#302a24') expect(css).toContain('--text-secondary:#655b50') expect(css).toContain('--border-cream:#e4d5c0') expect(css).toContain('--accent:#f15a29') expect(css).toContain('--success:#687e61') expect(css).toContain('--radius-list:15px') expect(css).toContain('font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC"') expect(css).toContain('font-variant-numeric:tabular-nums') expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:var(--success)') expect(css).toContain('.habit-progress-bar::-moz-progress-bar{background:var(--success)') expect(css).not.toContain('linear-gradient(90deg,#f7a066,var(--accent))') 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:#f7efe3') expect(css).toContain('main{background:var(--surface-base)}') expect(css).toContain('.detail,.bottom{background:var(--surface-raised)') expect(css).toContain('.app-sheet,.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('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)') expect(css).toContain('--scrim:rgba(45,38,31,.38)') }) 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{background:var(--surface-raised)') expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)') expect(css).toContain('@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(56px + var(--safe-area-bottom));') expect(css).toContain('border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom)') expect(css).toMatch(/\.bottom button\{[^}]*min-height:44px/) expect(css).toMatch(/\.bottom button\.active\{[^}]*background:transparent/) expect(css).toContain('.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;') expect(css).toContain('@media(prefers-reduced-motion:reduce)') }) }) describe('approved UI detail direction', () => { it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => { const ordinaryStart = app.indexOf('
', ordinaryStart)) expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)") expect(ordinaryRows).not.toContain('task-detail-trigger') expect(ordinaryRows).not.toContain('selectTask(subtask)') expect(ordinaryRows).not.toContain('aria-label="删除任务"') expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"') expect(ordinaryRows).toContain('restoreTask(node.task)') expect(ordinaryRows).toContain('purgeTask(node.task)') const overdue = app.slice(app.indexOf('
{ expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"') expect(app).toContain('') expect(app).not.toContain('task-detail-trigger') expect(css).not.toContain('.task-detail-trigger') }) it('uses the approved paper-flow task detail structure and order', () => { const detail = app.slice(app.indexOf('', app.indexOf('清单') expect(detail).toContain('截止日期') expect(detail).toContain('时间') expect(detail).toContain('重复') expect(detail).toContain('