198 lines
15 KiB
TypeScript
198 lines
15 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { loadTaskOpenTotal } from './lib/task-open-total'
|
|
|
|
const app = readFileSync('src/App.vue', 'utf8')
|
|
const countdown = readFileSync('src/CountdownPanel.vue', 'utf8')
|
|
const habits = readFileSync('src/MvpPanel.vue', 'utf8')
|
|
const css = readFileSync('src/style.css', 'utf8')
|
|
const memoCss = readFileSync('src/memo.css', 'utf8')
|
|
const memoPanel = readFileSync('src/MemoPanel.vue', 'utf8')
|
|
const memoRow = readFileSync('src/components/MemoRow.vue', 'utf8')
|
|
|
|
describe('task-list open summary reconciliation', () => {
|
|
it('returns primary data without waiting for the open-total request', async () => {
|
|
let resolveSummary!: (value: { total: number }) => void
|
|
const summary = new Promise<{ total: number }>((resolve) => { resolveSummary = resolve })
|
|
const committed: Array<number | null> = []
|
|
const result = await loadTaskOpenTotal(
|
|
async () => summary,
|
|
() => true,
|
|
value => committed.push(value),
|
|
)
|
|
expect(result).toBeUndefined()
|
|
expect(committed).toEqual([])
|
|
resolveSummary({ total: 4 })
|
|
await summary
|
|
await Promise.resolve()
|
|
expect(committed).toEqual([4])
|
|
})
|
|
|
|
it('keeps null on failure and ignores stale list responses', async () => {
|
|
const failed: Array<number | null> = []
|
|
loadTaskOpenTotal(async () => { throw new Error('summary failed') }, () => true, value => failed.push(value))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(failed).toEqual([])
|
|
|
|
let current = true
|
|
let resolveSummary!: (value: { total: number }) => void
|
|
const summary = new Promise<{ total: number }>((resolve) => { resolveSummary = resolve })
|
|
const committed: Array<number | null> = []
|
|
loadTaskOpenTotal(async () => summary, () => current, value => committed.push(value))
|
|
current = false
|
|
resolveSummary({ total: 9 })
|
|
await summary
|
|
await Promise.resolve()
|
|
expect(committed).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('approved five-detail polish', () => {
|
|
it('does not expose task-search controls or styles', () => {
|
|
expect(app).not.toContain('mobileSearchOpen')
|
|
expect(app).not.toContain('task-search-panel')
|
|
expect(app).not.toContain('handleTaskSearchKeydown')
|
|
expect(css).not.toContain('.task-search-toggle')
|
|
expect(css).not.toContain('.search-reveal{')
|
|
})
|
|
|
|
it('keeps the countdown focus compact and detail titles unique', () => {
|
|
const focusStart = countdown.indexOf('class="countdown-focus"')
|
|
const focus = countdown.slice(focusStart, countdown.indexOf('</button>', focusStart))
|
|
expect(focus).not.toContain('置顶的重要日子')
|
|
expect(focus).not.toContain('下一个重要日子')
|
|
expect(focus).not.toContain('countdown-copy')
|
|
expect(focus).toContain('<h3>{{focusItem.title}}</h3>')
|
|
expect(countdown).not.toContain('<small>重要日子详情</small>')
|
|
expect(habits).not.toContain('<small>习惯详情</small>')
|
|
expect(countdown).toContain('title-id="countdown-detail-title"')
|
|
expect(habits).toContain('title-id="habit-detail-title"')
|
|
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
|
|
})
|
|
|
|
it('uses approved Today-sourced page headers for task lists and Upcoming without changing Trash IA', () => {
|
|
expect(app).toContain('v-if="activeView===\'tasks\' || activeView===\'upcoming\'" class="list-page-context"')
|
|
expect(app).toContain('<h1 class="list-page-title" :title="activeName">{{ activeName }}</h1>')
|
|
expect(app).toContain('<p class="list-page-summary">{{ taskOpenTotal === null ? \'待完成统计暂不可用\' : `还有 ${taskOpenTotal} 项待完成` }}</p>')
|
|
expect(app).toContain('<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />')
|
|
expect(app).toContain("v-if=\"activeView==='tasks' || activeView==='upcoming'\" id=\"task-list-heading\" class=\"list-section-heading\"")
|
|
expect(app).toContain('<span id="task-list-title" class="list-section-title">任务</span>')
|
|
expect(app).toContain("v-if=\"activeView==='tasks' && taskReorderAvailable\"")
|
|
expect(app).toContain('<span class="list-section-count">{{ totalTasks }}</span>')
|
|
expect(app).not.toContain("v-if=\"activeView==='tasks' && totalPages > 1\" class=\"list-page-meta\"")
|
|
const taskListEnd = app.indexOf('</section>', app.indexOf('class=\"task-list plain-list\"'))
|
|
expect(app.indexOf('class=\"pager\"')).toBeGreaterThan(taskListEnd)
|
|
expect(app).toContain('ref="taskListElement"')
|
|
expect(app).toContain("totalPages > 1 && (activeView!=='today' || !todaySectionCollapse.tasks)")
|
|
expect(app).toContain("taskListElement.value?.scrollIntoView({ block: 'start' })")
|
|
expect(app).not.toContain('class="list-page-meta" aria-live="polite"')
|
|
expect(app).toContain('<nav v-if="totalPages > 1 && (activeView!==\'today\' || !todaySectionCollapse.tasks)" class="pager" aria-label="任务分页">')
|
|
expect(app).toContain('class="pager-button pager-button--previous"')
|
|
expect(app).toContain('<ChevronLeft aria-hidden="true"/><span>上一页</span>')
|
|
expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>')
|
|
expect(app).toContain('class="pager-button pager-button--next"')
|
|
expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>')
|
|
expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:8px 0 14px;border-top:1px solid #e8e0d5;background:transparent}')
|
|
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 8px;display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:9px;background:transparent;color:#b7421e;font-size:13px;font-weight:650;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}')
|
|
expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}')
|
|
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 4px}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}')
|
|
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
|
|
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
|
|
expect(app).not.toContain('class="list-search-clear"')
|
|
expect(app).not.toContain('task-search-panel')
|
|
expect(app).not.toContain("activeView==='tasks' && (totalPages > 1 || totalTasks > 0)")
|
|
expect(app).toContain(":aria-labelledby=\"activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined\"")
|
|
const taskTopbar = app.slice(app.indexOf('<header class="topbar"'), app.indexOf('</header>'))
|
|
expect(taskTopbar).not.toContain('CompletedFilterPill v-if="activeView!==\'today\'"')
|
|
expect(app).toContain('v-if="activeView===\'trash\'" class="list-toolbar"')
|
|
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
|
|
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
|
|
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--muted);font-size:13px}')
|
|
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
|
|
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
|
|
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
|
|
expect(css).not.toContain('.list-search-reveal{')
|
|
expect(css).toContain('@media(max-width:720px){')
|
|
expect(css).not.toContain('top:-62px')
|
|
expect(css).toContain('.today-context .completed-filter-pill:hover:not(:disabled),.today-context .completed-filter-pill:active:not(:disabled),.list-inline-filter:hover:not(:disabled),.list-inline-filter:active:not(:disabled),.habit-inline-filter:hover:not(:disabled),.habit-inline-filter:active:not(:disabled){background:transparent;border-color:transparent}')
|
|
expect(css).toContain('.today-inline-filter,.list-inline-filter,.habit-inline-filter{-webkit-tap-highlight-color:transparent}')
|
|
expect(css).toContain('.list-inline-filter .completed-filter-pill__track,.habit-inline-filter .completed-filter-pill__track{width:31px;height:18px;background:#d8d0c5}')
|
|
expect(css).toContain('.list-inline-filter .completed-filter-pill__thumb,.habit-inline-filter .completed-filter-pill__thumb{width:14px;height:14px}')
|
|
expect(css).toContain('.list-section-heading{width:min(100%,630px);min-height:44px;margin:0 auto;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}')
|
|
})
|
|
|
|
it('uses approved Today-sourced page headers for full Habits and keeps archive lazy section', () => {
|
|
expect(habits).toContain('v-if="view === \'habits\'" class="habit-page-context"')
|
|
expect(habits).toContain('<h1 class="list-page-title">习惯</h1>')
|
|
expect(habits).toContain('<p class="list-page-summary">今天还有 {{ todayHabitSummary.total - todayHabitSummary.completed }} 项</p>')
|
|
expect(habits).toContain('class="completed-filter-pill habit-inline-filter"')
|
|
expect(habits).toContain('@update:model-value="$emit(\'update:showCompleted\', $event)"')
|
|
expect(habits).toContain('class="list-section-heading habit-section-heading"')
|
|
expect(habits).toContain('<span class="list-section-title">今日习惯</span>')
|
|
expect(habits).toContain('<span class="list-section-count">{{ todayHabitSummary.total }}</span>')
|
|
expect(habits).not.toContain('<span class="list-section-count">{{ visibleHabits.length }}</span>')
|
|
expect(habits).not.toContain('今日完成 {{')
|
|
expect(habits).toContain('class="habit-archive-section"')
|
|
expect(css).toContain('.habit-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
|
|
expect(css).toContain('.habit-section-heading{margin-top:0}')
|
|
expect(css).toContain('main.list-main>.mvp-view{gap:0}')
|
|
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
|
|
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{border-top:1px solid #e8e0d5}')
|
|
})
|
|
|
|
it('uses one 58px plain-list contract for active task and habit rows', () => {
|
|
expect(app).toContain("class=\"task-list plain-list\"")
|
|
expect(app).toContain("'task-row--trash':activeView==='trash'")
|
|
expect(app).toContain(':role="activeView===\'trash\' ? undefined : \'button\'"')
|
|
expect(app).toContain(':tabindex="activeView===\'trash\' ? undefined : 0"')
|
|
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
|
|
expect(app).toContain('v-if="activeView!==\'trash\'" class="task-check"')
|
|
expect(habits).toContain('class=\"habit-list plain-list\"')
|
|
expect(habits).toContain('class=\"habit-row habit-row--full swipeable\"')
|
|
expect(css).toContain('.plain-list{display:grid;gap:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible}')
|
|
expect(css).toContain('.plain-list-row,.plain-list>.task-row,.plain-list>.habit-row{height:58px;min-height:58px;max-height:58px;background:transparent;border:0;border-bottom:1px solid #e8e0d5;border-radius:0;box-shadow:none}')
|
|
expect(css).toContain('.task-row{grid-template-columns:44px minmax(0,1fr) auto}')
|
|
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
|
|
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
|
|
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
|
|
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
|
|
expect(css).toContain('.task-check{width:44px;')
|
|
expect(css).toContain('.plain-list .habit-check{margin-left:0}')
|
|
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
|
})
|
|
|
|
it('moves habit history out of every active list row and retains a visible status tail', () => {
|
|
const todayRows = habits.slice(habits.indexOf('class="habit-list plain-list today-habit-list"'), habits.indexOf('<!-- 完整习惯列表 -->'))
|
|
const fullRows = habits.slice(habits.indexOf('class="habit-list plain-list">'), habits.indexOf('class="habit-archive-section"'))
|
|
for (const rows of [todayRows, fullRows]) {
|
|
expect(rows).not.toContain('habit-week')
|
|
expect(rows).toContain('<progress v-if="h.kind === \'numeric\'" class="habit-progress-bar"')
|
|
expect(rows).not.toContain('habit-state-note')
|
|
expect(rows).toContain('class="habit-row-meta"')
|
|
expect(rows).toContain(':aria-label="habitRowStatus(h)"')
|
|
}
|
|
})
|
|
|
|
it('gives task and habit rows distinct hover, selected, and completed feedback', () => {
|
|
expect(css).toContain('@media(hover:hover) and (pointer:fine){.plain-list>.task-row:not(.task-row--trash):hover,.plain-list>.habit-row:hover{background:#fff8ee}}')
|
|
expect(css).toContain('@media(hover:hover) and (pointer:fine){.task-row:hover:not(.reordering):not(.task-row--trash),.habit-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}}')
|
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected,.plain-list>.task-row:not(.task-row--trash).selected.done,.plain-list>.task-row:not(.task-row--trash).selected.overdue-task,.plain-list>.habit-row.selected,.plain-list>.habit-row.selected.done{background:#fff4e5}')
|
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).selected::before,.plain-list>.habit-row.selected::before{background:var(--accent)}')
|
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).done,.plain-list>.habit-row.done{background:#fbf7f0}')
|
|
expect(css).toContain('.plain-list>.task-row:not(.task-row--trash).overdue-task{background:#fff7f4}')
|
|
expect(habits.match(/selected:selectedHabit\?\.id===h\.id/g)?.length).toBe(2)
|
|
})
|
|
|
|
it('renders memo rows with title and time only at a fixed compact height', () => {
|
|
expect(memoPanel).not.toContain('memoPlainTextSummary')
|
|
expect(memoRow).not.toContain('memoPlainTextSummary')
|
|
expect(memoRow).not.toContain('memo-row__excerpt')
|
|
expect(memoRow).not.toContain('memo.excerpt')
|
|
expect(memoRow).toContain('<strong>{{ memo.title }}</strong>')
|
|
expect(memoRow).toContain('<time :datetime="memo.updated_at">{{ updated }}</time>')
|
|
expect(memoCss).not.toContain('.memo-row__excerpt')
|
|
expect(memoCss).toContain('.memo-row time{grid-column:2;grid-row:1;')
|
|
})
|
|
})
|