feat: refine task and countdown interactions
This commit is contained in:
@@ -827,6 +827,19 @@ async def pin_countdown(countdown_id: UUID, user: User = Depends(current_user),
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.delete("/countdowns/{countdown_id}/pin")
|
||||
async def unpin_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
if row.archived_at is not None:
|
||||
raise HTTPException(409, "已归档倒数日不能取消置顶")
|
||||
row.pinned = False
|
||||
row.updated_at = utcnow()
|
||||
audit(db, user.id, "update", "countdown", row.id)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return countdown_dict(row)
|
||||
|
||||
|
||||
@router.delete("/countdowns/{countdown_id}", status_code=204)
|
||||
async def archive_countdown(countdown_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_countdown(db, user.id, countdown_id)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { APIRequestContext, Locator, Page, Request } from '@playwright/test'
|
||||
import { allowExpectedError, expect, test } from './fixtures'
|
||||
|
||||
type TaskFixture = { id: string; title: string; due_at: string | null; due_has_time: boolean; version: number }
|
||||
type RecurrenceFixture = { id: string; task_id: string; rrule: string | null }
|
||||
type Mutation = { method: string; pathname: string; body: Record<string, unknown> | null }
|
||||
|
||||
async function csrf(request: APIRequestContext) {
|
||||
const state = await request.storageState()
|
||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
|
||||
}
|
||||
|
||||
async function mutate(
|
||||
request: APIRequestContext,
|
||||
baseURL: string,
|
||||
path: string,
|
||||
options: Parameters<APIRequestContext['fetch']>[1],
|
||||
) {
|
||||
return request.fetch(path, {
|
||||
...options,
|
||||
headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL },
|
||||
})
|
||||
}
|
||||
|
||||
async function inboxId(request: APIRequestContext) {
|
||||
const response = await request.get('/api/v1/bootstrap')
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const inbox = (await response.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
return inbox.id as string
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
request: APIRequestContext,
|
||||
baseURL: string,
|
||||
title: string,
|
||||
options: { dueAt?: string; recurrence?: 'daily' } = {},
|
||||
) {
|
||||
const response = await mutate(request, baseURL, '/api/v1/tasks', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
title,
|
||||
list_id: await inboxId(request),
|
||||
...(options.dueAt ? { due_at: options.dueAt, due_has_time: false } : {}),
|
||||
...(options.recurrence === 'daily' ? { trigger_mode: 'scheduled', rrule: 'FREQ=DAILY;INTERVAL=1' } : {}),
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return response.json() as Promise<TaskFixture>
|
||||
}
|
||||
|
||||
async function openTaskDetail(page: Page, title: string) {
|
||||
await page.goto('/')
|
||||
const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
|
||||
const menu = page.locator('.topbar').getByRole('button', { name: /菜单$/ })
|
||||
const inboxBox = await inbox.boundingBox()
|
||||
if (!inboxBox || inboxBox.x < 0 || inboxBox.x >= page.viewportSize()!.width) await menu.click()
|
||||
await expect(inbox).toBeInViewport()
|
||||
await inbox.click()
|
||||
const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||
await expect(row).toHaveCount(1)
|
||||
await row.locator('.task-main').click()
|
||||
const detail = page.getByRole('dialog', { name: '任务详情' })
|
||||
await expect(detail).toBeVisible()
|
||||
await expect(detail.getByRole('button', { name: '保存更改' })).toBeEnabled()
|
||||
return detail
|
||||
}
|
||||
|
||||
async function reopenTaskDetail(page: Page, title: string) {
|
||||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeHidden()
|
||||
await page.reload()
|
||||
return openTaskDetail(page, title)
|
||||
}
|
||||
|
||||
function repeatSelect(detail: Locator) {
|
||||
return detail.locator('label').filter({ hasText: /^重复/ }).locator('select')
|
||||
}
|
||||
|
||||
function recordSaveMutations(page: Page, taskId: string) {
|
||||
const mutations: Mutation[] = []
|
||||
const listener = (request: Request) => {
|
||||
const url = new URL(request.url())
|
||||
if (!['PATCH', 'POST', 'DELETE'].includes(request.method())) return
|
||||
if (url.pathname !== `/api/v1/tasks/${taskId}` && !url.pathname.startsWith('/api/v1/recurrences')) return
|
||||
mutations.push({ method: request.method(), pathname: url.pathname, body: request.postDataJSON() ?? null })
|
||||
}
|
||||
page.on('request', listener)
|
||||
return { mutations, stop: () => page.off('request', listener) }
|
||||
}
|
||||
|
||||
async function clickSaveAndWait(page: Page, taskId: string, recurrenceWrite?: { method: 'POST' | 'PATCH' | 'DELETE'; path: string }) {
|
||||
const responses = [
|
||||
page.waitForResponse(response => response.url().includes(`/api/v1/tasks/${taskId}`) && response.request().method() === 'PATCH' && response.ok()),
|
||||
]
|
||||
if (recurrenceWrite) {
|
||||
responses.push(page.waitForResponse(response => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === recurrenceWrite.path && response.request().method() === recurrenceWrite.method && response.ok()
|
||||
}))
|
||||
}
|
||||
await Promise.all([...responses, page.getByRole('button', { name: '保存更改' }).click()])
|
||||
await expect(page.locator('.toast')).toContainText('已保存')
|
||||
}
|
||||
|
||||
async function getRecurrence(request: APIRequestContext, taskId: string) {
|
||||
const response = await request.get(`/api/v1/tasks/${taskId}/recurrence`)
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return response.json() as Promise<RecurrenceFixture | null>
|
||||
}
|
||||
|
||||
test('clearing recurring task deadline persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
|
||||
const task = await createTask(request, baseURL!, `E2E 清除日期 ${testInfo.project.name}`, {
|
||||
dueAt: '2030-06-15T23:59:00Z', recurrence: 'daily',
|
||||
})
|
||||
let detail = await openTaskDetail(page, task.title)
|
||||
await expect(detail.locator('input[aria-label="截止日期"]')).not.toHaveValue('')
|
||||
await expect(repeatSelect(detail)).toHaveValue('daily')
|
||||
|
||||
const capture = recordSaveMutations(page, task.id)
|
||||
await detail.getByRole('button', { name: '清除截止日期' }).click()
|
||||
await clickSaveAndWait(page, task.id)
|
||||
capture.stop()
|
||||
|
||||
expect(capture.mutations).toHaveLength(1)
|
||||
expect(capture.mutations[0]).toMatchObject({ method: 'PATCH', pathname: `/api/v1/tasks/${task.id}` })
|
||||
expect(capture.mutations[0].body).toMatchObject({ due_at: null, due_has_time: false })
|
||||
expect(capture.mutations.some(item => item.method === 'DELETE' && item.pathname.startsWith('/api/v1/recurrences/'))).toBe(false)
|
||||
expect(await getRecurrence(request, task.id)).toBeNull()
|
||||
|
||||
detail = await reopenTaskDetail(page, task.title)
|
||||
await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue('')
|
||||
await expect(repeatSelect(detail)).toHaveValue('none')
|
||||
})
|
||||
|
||||
test('choosing no repeat keeps the deadline and persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
|
||||
const task = await createTask(request, baseURL!, `E2E 取消重复 ${testInfo.project.name}`, {
|
||||
dueAt: '2030-06-16T23:59:00Z', recurrence: 'daily',
|
||||
})
|
||||
let detail = await openTaskDetail(page, task.title)
|
||||
const originalDate = await detail.locator('input[aria-label="截止日期"]').inputValue()
|
||||
const recurrence = await getRecurrence(request, task.id)
|
||||
expect(recurrence).not.toBeNull()
|
||||
|
||||
const capture = recordSaveMutations(page, task.id)
|
||||
await repeatSelect(detail).selectOption('none')
|
||||
allowExpectedError(page, `requestfailed: DELETE http://127.0.0.1:5173/api/v1/recurrences/${recurrence!.id} net::ERR_ABORTED`)
|
||||
await clickSaveAndWait(page, task.id, { method: 'DELETE', path: `/api/v1/recurrences/${recurrence!.id}` })
|
||||
capture.stop()
|
||||
|
||||
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
|
||||
`PATCH /api/v1/tasks/${task.id}`,
|
||||
`DELETE /api/v1/recurrences/${recurrence!.id}`,
|
||||
])
|
||||
expect(capture.mutations[0].body).toMatchObject({ due_has_time: false })
|
||||
expect(capture.mutations[0].body?.due_at).not.toBeNull()
|
||||
expect(await getRecurrence(request, task.id)).toBeNull()
|
||||
|
||||
detail = await reopenTaskDetail(page, task.title)
|
||||
await expect(repeatSelect(detail)).toHaveValue('none')
|
||||
await expect(detail.locator('input[aria-label="截止日期"]')).toHaveValue(originalDate)
|
||||
})
|
||||
|
||||
test('editing an existing rule to weekly persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
|
||||
const task = await createTask(request, baseURL!, `E2E 修改重复 ${testInfo.project.name}`, {
|
||||
dueAt: '2030-06-17T23:59:00Z', recurrence: 'daily',
|
||||
})
|
||||
let detail = await openTaskDetail(page, task.title)
|
||||
const recurrence = await getRecurrence(request, task.id)
|
||||
expect(recurrence).not.toBeNull()
|
||||
|
||||
const capture = recordSaveMutations(page, task.id)
|
||||
await repeatSelect(detail).selectOption('weekly')
|
||||
await clickSaveAndWait(page, task.id, { method: 'PATCH', path: `/api/v1/recurrences/${recurrence!.id}` })
|
||||
capture.stop()
|
||||
|
||||
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
|
||||
`PATCH /api/v1/tasks/${task.id}`,
|
||||
`PATCH /api/v1/recurrences/${recurrence!.id}`,
|
||||
])
|
||||
expect(capture.mutations[1].body).toMatchObject({ trigger_mode: 'scheduled' })
|
||||
expect(capture.mutations[1].body?.rrule).toContain('FREQ=WEEKLY')
|
||||
expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=WEEKLY')
|
||||
|
||||
detail = await reopenTaskDetail(page, task.title)
|
||||
await expect(repeatSelect(detail)).toHaveValue('weekly')
|
||||
await expect(detail.locator('.repeat-custom-fields')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('adding a daily rule persists after reload and detail reopen', async ({ page, request, baseURL }, testInfo) => {
|
||||
const task = await createTask(request, baseURL!, `E2E 新增重复 ${testInfo.project.name}`, {
|
||||
dueAt: '2030-06-18T23:59:00Z',
|
||||
})
|
||||
let detail = await openTaskDetail(page, task.title)
|
||||
expect(await getRecurrence(request, task.id)).toBeNull()
|
||||
|
||||
const capture = recordSaveMutations(page, task.id)
|
||||
await repeatSelect(detail).selectOption('daily')
|
||||
await clickSaveAndWait(page, task.id, { method: 'POST', path: '/api/v1/recurrences' })
|
||||
capture.stop()
|
||||
|
||||
expect(capture.mutations.map(item => `${item.method} ${item.pathname}`)).toEqual([
|
||||
`PATCH /api/v1/tasks/${task.id}`,
|
||||
'POST /api/v1/recurrences',
|
||||
])
|
||||
expect(capture.mutations[1].body).toMatchObject({ task_id: task.id, trigger_mode: 'scheduled' })
|
||||
expect(capture.mutations[1].body?.rrule).toContain('FREQ=DAILY')
|
||||
expect((await getRecurrence(request, task.id))?.rrule).toContain('FREQ=DAILY')
|
||||
|
||||
detail = await reopenTaskDetail(page, task.title)
|
||||
await expect(repeatSelect(detail)).toHaveValue('daily')
|
||||
})
|
||||
@@ -193,8 +193,9 @@ test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await toggle.click()
|
||||
const closeToggle = page.getByRole('button', { name: '收起搜索备忘录' })
|
||||
const input = page.getByRole('textbox', { name: '搜索备忘录' })
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(closeToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(`保留查询-${testInfo.project.name}`)
|
||||
const openPanel = page.locator('#memo-search-panel')
|
||||
@@ -207,7 +208,7 @@ test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and
|
||||
expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1)
|
||||
expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth)
|
||||
|
||||
await toggle.click()
|
||||
await closeToggle.click()
|
||||
await expect(openPanel).toBeHidden()
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
+40
-10
@@ -5,7 +5,7 @@ import {
|
||||
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
|
||||
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
|
||||
} from 'lucide-vue-next'
|
||||
import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
@@ -143,6 +143,10 @@ const composeTime = ref('12:00')
|
||||
const composeCalendarOpen = ref(false)
|
||||
const composeDateButton = ref<HTMLButtonElement | null>(null)
|
||||
const composeTimePicker = ref<HTMLInputElement | null>(null)
|
||||
const selectedDueDate = ref('')
|
||||
const selectedDueHasTime = ref(false)
|
||||
const selectedDueTime = ref('12:00')
|
||||
const selectedDueTimePicker = ref<HTMLInputElement | null>(null)
|
||||
const composePriority = ref(0)
|
||||
const composeDescription = ref('')
|
||||
const composeRepeat = ref<RepeatOption>('none')
|
||||
@@ -917,11 +921,14 @@ async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: stri
|
||||
return false
|
||||
}
|
||||
task.title = normalized.value
|
||||
const due = buildTaskDueDraft({ date: selectedDueDate.value, hasTime: selectedDueHasTime.value, time: selectedDueTime.value })
|
||||
try {
|
||||
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
|
||||
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>, false)
|
||||
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), ...due, list_id: task.list_id } as Partial<Task>, false)
|
||||
const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken
|
||||
if (selectedTask.value?.id === task.id && selectionMatches) selectedTask.value = { ...selectedTask.value, ...updated }
|
||||
if (selectedTask.value?.id === task.id && selectionMatches) {
|
||||
selectedTask.value = { ...selectedTask.value, ...updated }
|
||||
selectedDueHasTime.value = Boolean(updated.due_has_time)
|
||||
}
|
||||
await refreshTodayAfterTaskSave()
|
||||
if (options?.showSuccess !== false) toast('已保存')
|
||||
return updated
|
||||
@@ -936,18 +943,22 @@ async function saveSelectedTaskChanges() {
|
||||
if (!taskId) return
|
||||
savingSelectedTask.value = true
|
||||
const selectionToken = recurrenceLoadToken
|
||||
const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'
|
||||
const repeatConfig = structuredClone(selectedRepeatConfig.value)
|
||||
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
|
||||
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
|
||||
const afterCompletionDays = selectedAfterCompletionDays.value
|
||||
const recurrence = selectedTaskRecurrence.value
|
||||
selectedRepeatError.value = ''
|
||||
try {
|
||||
const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })
|
||||
if (!taskSaved || recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||
const repeatRecurrence = taskSaved.due_at ? recurrence : null
|
||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)
|
||||
if (!taskSaved.due_at) {
|
||||
selectedTaskRecurrence.value = null
|
||||
selectedTaskRepeat.value = 'none'
|
||||
} else {
|
||||
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
|
||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||
selectedTaskRecurrence.value = updatedRecurrence
|
||||
}
|
||||
toast('已保存')
|
||||
} catch (reason) {
|
||||
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
|
||||
@@ -1002,8 +1013,24 @@ function closeTaskDetail() {
|
||||
mobileDetail.value = false
|
||||
selectedTask.value = null
|
||||
}
|
||||
function addSelectedDueTime() {
|
||||
selectedDueHasTime.value = true
|
||||
nextTick(() => {
|
||||
const picker = selectedDueTimePicker.value as (HTMLInputElement & { showPicker?: () => void }) | null
|
||||
try { picker?.showPicker?.() } catch { picker?.focus() }
|
||||
})
|
||||
}
|
||||
function clearSelectedDueDate() {
|
||||
selectedDueDate.value = ''
|
||||
selectedDueHasTime.value = false
|
||||
selectedTaskRepeat.value = 'none'
|
||||
}
|
||||
function selectTask(task: Task) {
|
||||
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
|
||||
const due = parseTaskDueDraft(task.due_at, task.due_has_time)
|
||||
selectedDueDate.value = due.date
|
||||
selectedDueHasTime.value = due.hasTime
|
||||
selectedDueTime.value = due.time
|
||||
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
|
||||
void loadTaskRecurrence(task)
|
||||
}
|
||||
@@ -1543,8 +1570,11 @@ onUnmounted(() => {
|
||||
<div class="detail-form">
|
||||
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value"></label>
|
||||
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<div class="task-detail-due-row">
|
||||
<span>截止时间</span>
|
||||
<div class="task-detail-due-controls"><input v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input" type="date" aria-label="截止日期"><button v-if="selectedDueDate" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearSelectedDueDate"><X/></button><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip"><span>时间</span><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label></div>
|
||||
</div>
|
||||
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
|
||||
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
|
||||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
|
||||
|
||||
@@ -195,7 +195,28 @@ describe('countdown modal accessibility', () => {
|
||||
newPin.resolve(json({})); await flush()
|
||||
})
|
||||
|
||||
it('keeps the normal current-detail pin flow working', async () => {
|
||||
it('pins and unpins from detail without duplicate requests', async () => {
|
||||
const mutation = deferred<Response>()
|
||||
const pinned = { ...countdown, pinned: true }
|
||||
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
||||
if (url.endsWith('/countdowns/c1/pin') && options?.method === 'DELETE') return mutation.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([pinned]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
const button = detailButton('取消置顶')
|
||||
button.click(); button.click()
|
||||
await nextTick()
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
||||
expect(button.disabled).toBe(true)
|
||||
mutation.resolve(json({ ...pinned, pinned: false })); await flush()
|
||||
expect(notices).toEqual(['已取消置顶'])
|
||||
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps normal current-detail pin flow working', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
|
||||
@@ -161,7 +161,7 @@ async function save() {
|
||||
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
})
|
||||
}
|
||||
async function pin(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已置顶');closeDetail();await load(true,false)})}
|
||||
async function setPinned(item:Countdown){const nextPinned=!item.pinned;await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:nextPinned?'POST':'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice',nextPinned?'已置顶':'已取消置顶');closeDetail();await load(true,false)})}
|
||||
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
|
||||
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已永久删除')})}
|
||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" :disabled="busy" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
<footer class="app-sheet__footer"><button type="button" :disabled="busy" @click="setPinned(detailItem)"><Pin/>{{detailItem.pinned?'取消置顶':'置顶'}}</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
|
||||
|
||||
@@ -211,7 +211,7 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
||||
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
||||
<div class="memo-toolbar">
|
||||
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
||||
<button ref="searchToggle" class="memo-search-toggle" type="button" aria-label="展开搜索备忘录" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
|
||||
<button ref="searchToggle" class="memo-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索备忘录' : '展开搜索备忘录'" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
|
||||
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
|
||||
</div>
|
||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||
|
||||
@@ -748,7 +748,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||||
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
||||
<div class="habit-main">
|
||||
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
|
||||
<span class="habit-name">{{ h.name }}</span>
|
||||
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
|
||||
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
|
||||
|
||||
@@ -14,8 +14,10 @@ describe('add-task CalendarPicker integration', () => {
|
||||
expect(app).toContain("if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }")
|
||||
expect(app).not.toContain('ref="composeDatePicker"')
|
||||
expect(app).not.toContain('function openComposeDuePicker()')
|
||||
expect(app).toContain('type="datetime-local"')
|
||||
expect(app).not.toContain('type="datetime-local"')
|
||||
expect(app).toContain('v-model="composeTime" type="time"')
|
||||
expect(app).toContain('v-model="selectedDueDate"')
|
||||
expect(app).toContain('v-model="selectedDueTime" type="time"')
|
||||
})
|
||||
|
||||
it('uses a fixed desktop popover and opaque mobile bottom sheet above task compose', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -136,4 +136,23 @@ describe('task utilities', () => {
|
||||
process.env.TZ = original
|
||||
expect(toDateTimeLocal(null)).toBe('')
|
||||
})
|
||||
|
||||
it('parses date-only and timed task deadlines without inventing a time', () => {
|
||||
const original = process.env.TZ
|
||||
process.env.TZ = 'Asia/Shanghai'
|
||||
expect(parseTaskDueDraft('2026-09-05T15:59:00Z', false)).toEqual({ date: '2026-09-05', hasTime: false, time: '12:00' })
|
||||
expect(parseTaskDueDraft('2026-09-05T12:30:00Z', true)).toEqual({ date: '2026-09-05', hasTime: true, time: '20:30' })
|
||||
expect(parseTaskDueDraft(null, true)).toEqual({ date: '', hasTime: false, time: '12:00' })
|
||||
process.env.TZ = original
|
||||
})
|
||||
|
||||
it('builds date-only, timed, toggled and cleared task deadlines in local time', () => {
|
||||
const original = process.env.TZ
|
||||
process.env.TZ = 'Asia/Shanghai'
|
||||
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: false, time: '08:15' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
|
||||
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '08:15' })).toEqual({ due_at: '2026-09-05T00:15:00.000Z', due_has_time: true })
|
||||
expect(buildTaskDueDraft({ date: '2026-09-05', hasTime: true, time: '' })).toEqual({ due_at: '2026-09-05T15:59:00.000Z', due_has_time: false })
|
||||
expect(buildTaskDueDraft({ date: '', hasTime: true, time: '08:15' })).toEqual({ due_at: null, due_has_time: false })
|
||||
process.env.TZ = original
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,6 +217,22 @@ export function defaultTaskDueAt(now = new Date()) {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export type TaskDueDraft = { date: string; hasTime: boolean; time: string }
|
||||
|
||||
export function parseTaskDueDraft(value: string | null | undefined, dueHasTime: boolean): TaskDueDraft {
|
||||
const local = toDateTimeLocal(value)
|
||||
if (!local) return { date: '', hasTime: false, time: '12:00' }
|
||||
const [date, time = '12:00'] = local.split('T')
|
||||
return { date, hasTime: dueHasTime, time: dueHasTime ? time : '12:00' }
|
||||
}
|
||||
|
||||
export function buildTaskDueDraft(draft: TaskDueDraft) {
|
||||
if (!draft.date) return { due_at: null, due_has_time: false }
|
||||
const hasTime = draft.hasTime && Boolean(draft.time)
|
||||
const local = `${draft.date}T${hasTime ? draft.time : '23:59'}`
|
||||
return { due_at: fromDateTimeLocal(local), due_has_time: hasTime }
|
||||
}
|
||||
|
||||
export function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -246,6 +246,33 @@ describe('approved UI detail direction', () => {
|
||||
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
|
||||
})
|
||||
|
||||
it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => {
|
||||
const todayRows = mvpPanel.slice(mvpPanel.indexOf('class="habit-list today-habit-list"'), mvpPanel.indexOf('<!-- 完整习惯列表 -->'))
|
||||
expect(todayRows).toContain('role="button" tabindex="0"')
|
||||
expect(todayRows).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(todayRows).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(todayRows).toContain('@keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(todayRows).toContain('@click.stop="toggleHabitFromButton(h)"')
|
||||
})
|
||||
|
||||
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
|
||||
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
|
||||
expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
|
||||
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}')
|
||||
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
|
||||
})
|
||||
|
||||
it('compresses countdown focus without hiding its primary information', () => {
|
||||
expect(css).toMatch(/\.countdown-focus\{[^}]*min-height:140px/)
|
||||
expect(css).toMatch(/\.countdown-number strong\{[^}]*font-size:64px/)
|
||||
expect(countdownPanel).toContain('<h3>{{focusItem.title}}</h3>')
|
||||
expect(countdownPanel).toContain('<p>{{primaryDate(focusItem)}}</p>')
|
||||
})
|
||||
|
||||
it('uses the real Memo search state in its toggle name', () => {
|
||||
expect(readFileSync('src/MemoPanel.vue', 'utf8')).toContain(':aria-label="mobileSearchOpen ? \'收起搜索备忘录\' : \'展开搜索备忘录\'"')
|
||||
})
|
||||
|
||||
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)}')
|
||||
@@ -320,8 +347,13 @@ describe('completion feedback motion', () => {
|
||||
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
|
||||
expect(saveTaskBlock).not.toContain('/recurrences/')
|
||||
expect(saveTaskBlock).toContain('as Partial<Task>, false')
|
||||
expect(saveTaskBlock).toContain('selectedDueHasTime.value = Boolean(updated.due_has_time)')
|
||||
const unifiedSaveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges('), app.indexOf('async function removeTask('))
|
||||
expect(unifiedSaveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
|
||||
expect(unifiedSaveBlock).toContain('if (!taskSaved.due_at) {')
|
||||
expect(unifiedSaveBlock).toContain('selectedTaskRecurrence.value = null')
|
||||
expect(unifiedSaveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||
const dueRemovalBlock = unifiedSaveBlock.slice(unifiedSaveBlock.indexOf('if (!taskSaved.due_at) {'), unifiedSaveBlock.indexOf('} else {'))
|
||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||
})
|
||||
|
||||
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
||||
@@ -1062,8 +1094,8 @@ describe('task detail layout', () => {
|
||||
})
|
||||
|
||||
it('keeps the list, due datetime and repeat controls equally compact', () => {
|
||||
expect(app).toContain('<label class="task-detail-due-row">截止时间')
|
||||
expect(app).toContain('class="task-detail-due-input task-detail-field-input"')
|
||||
expect(app).toContain('<div class="task-detail-due-row">')
|
||||
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
|
||||
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
|
||||
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
|
||||
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
|
||||
@@ -1116,19 +1148,23 @@ describe('unified floating add interaction', () => {
|
||||
const saveBlock = app.slice(app.indexOf('async function saveSelectedTaskChanges()'), app.indexOf('async function removeTask'))
|
||||
expect(saveBlock).toContain("const taskId = selectedTask.value?.id")
|
||||
expect(saveBlock).toContain('const selectionToken = recurrenceLoadToken')
|
||||
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
|
||||
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
|
||||
expect(saveBlock).toContain('structuredClone(selectedRepeatConfig.value)')
|
||||
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
|
||||
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
|
||||
expect(saveBlock).toContain('const repeatRecurrence = taskSaved.due_at ? recurrence : null')
|
||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)')
|
||||
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
||||
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
|
||||
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
|
||||
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
|
||||
expect(dueRemovalBlock).not.toContain('saveRepeat(')
|
||||
expect(saveBlock).toContain("selectedRepeatError.value = ''")
|
||||
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'")
|
||||
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, repeatRecurrence)'))
|
||||
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
|
||||
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
|
||||
expect(saveBlock).toContain('savingSelectedTask.value = true')
|
||||
expect(saveBlock).toContain('savingSelectedTask.value = false')
|
||||
expect(saveBlock).toContain("const repeatValue = selectedTask.value?.due_at ? selectedTaskRepeat.value : 'none'")
|
||||
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
|
||||
expect(app).toContain(':disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges"')
|
||||
expect(app).not.toContain('@blur="saveTask()"')
|
||||
expect(app).not.toContain('@change="saveTask()"')
|
||||
|
||||
@@ -132,6 +132,12 @@ def test_countdown_crud_single_pin_archive_restore_and_purge(client):
|
||||
assert [item["title"] for item in active] == ["生日", "旅行"]
|
||||
assert [item["pinned"] for item in active] == [True, False]
|
||||
|
||||
unpinned = client.delete(f"/api/v1/countdowns/{second['id']}/pin")
|
||||
assert unpinned.status_code == 200
|
||||
assert unpinned.json()["pinned"] is False
|
||||
active = client.get("/api/v1/countdowns").json()
|
||||
assert [item["pinned"] for item in active] == [False, False]
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/v1/countdowns/{first['id']}",
|
||||
json={"title": "海边旅行", "event_date": "2026-09-12", "kind": "anniversary", "repeat_rule": "none", "icon": "🌊"},
|
||||
|
||||
Reference in New Issue
Block a user