test: improve recurrence and floating button coverage
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-19 13:57:09 +08:00
parent 9c0dc3a31f
commit dc2c5ff9c0
2 changed files with 705 additions and 0 deletions
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import FloatingAddButton from './FloatingAddButton.vue'
const cleanups: Array<() => void> = []
function pointerEvent(type: string, init: {
pointerId?: number
clientX?: number
clientY?: number
pointerType?: string
button?: number
isPrimary?: boolean
} = {}) {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperties(event, {
pointerId: { value: init.pointerId ?? 1 },
clientX: { value: init.clientX ?? 0 },
clientY: { value: init.clientY ?? 0 },
pointerType: { value: init.pointerType ?? 'touch' },
button: { value: init.button ?? 0 },
isPrimary: { value: init.isPrimary ?? true },
})
return event
}
async function mountButton(options: { show?: boolean; label?: string; reducedMotion?: boolean } = {}) {
const host = document.createElement('div')
document.body.append(host)
const show = ref(options.show ?? true)
const activations: Array<{ x: number; y: number }> = []
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({
matches: options.reducedMotion ?? false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}))
const app = createApp({
setup: () => () => h(FloatingAddButton, {
show: show.value,
label: options.label,
onActivate: (origin: { x: number; y: number }) => activations.push(origin),
}),
})
app.mount(host)
cleanups.push(() => { app.unmount(); host.remove() })
await nextTick()
return { host, show, activations, button: () => host.querySelector<HTMLButtonElement>('button') }
}
function setRect(button: HTMLButtonElement, left: number, top: number) {
Object.defineProperty(button, 'getBoundingClientRect', {
configurable: true,
value: () => ({ left, top, width: 56, height: 56, right: left + 56, bottom: top + 56, x: left, y: top, toJSON: () => ({}) }),
})
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 390)
vi.stubGlobal('innerHeight', 844)
document.documentElement.style.setProperty('--safe-area-bottom', '20px')
})
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup())
document.documentElement.style.removeProperty('--safe-area-bottom')
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('FloatingAddButton', () => {
it('renders an accessible native button and emits its visual center when clicked', async () => {
const { button, activations } = await mountButton({ label: '新建任务' })
const fab = button()!
setRect(fab, 100, 200)
expect(fab.tagName).toBe('BUTTON')
expect(fab.getAttribute('aria-label')).toBe('新建任务')
fab.focus()
expect(document.activeElement).toBe(fab)
fab.click()
expect(activations).toEqual([{ x: 128, y: 228 }])
})
it('reacts to show changes without emitting an activation', async () => {
const { button, show, activations } = await mountButton({ show: false })
expect(button()).toBeNull()
show.value = true
await nextTick()
expect(button()?.getAttribute('aria-label')).toBe('添加')
expect(activations).toEqual([])
})
it('drags, clamps, snaps to the nearest edge, and suppresses the trailing click', async () => {
const { button, activations } = await mountButton()
const fab = button()!
setRect(fab, 300, 700)
const capture = vi.fn()
Object.defineProperty(fab, 'setPointerCapture', { configurable: true, value: capture })
fab.dispatchEvent(pointerEvent('pointerdown', { pointerId: 7, clientX: 310, clientY: 710 }))
fab.dispatchEvent(pointerEvent('pointermove', { pointerId: 7, clientX: 50, clientY: 400 }))
await nextTick()
expect(capture).toHaveBeenCalledWith(7)
expect(fab.classList.contains('dragging')).toBe(true)
expect(fab.style.left).toBe('40px')
expect(fab.style.top).toBe('390px')
fab.dispatchEvent(pointerEvent('pointerup', { pointerId: 7, clientX: 50, clientY: 400 }))
await nextTick()
expect(fab.classList.contains('dragging')).toBe(false)
expect(fab.classList.contains('snapping')).toBe(true)
expect(fab.style.left).toBe('14px')
expect(fab.style.top).toBe('390px')
fab.click()
expect(activations).toEqual([])
vi.advanceTimersByTime(180)
fab.click()
expect(activations).toEqual([{ x: 328, y: 728 }])
vi.advanceTimersByTime(40)
await nextTick()
expect(fab.classList.contains('snapping')).toBe(false)
})
it('does not start dragging for secondary mouse or non-primary pointers', async () => {
const { button } = await mountButton()
const fab = button()!
setRect(fab, 100, 200)
fab.dispatchEvent(pointerEvent('pointerdown', { pointerType: 'mouse', button: 2 }))
fab.dispatchEvent(pointerEvent('pointermove', { clientX: 300, clientY: 500 }))
fab.dispatchEvent(pointerEvent('pointerdown', { pointerId: 2, isPrimary: false }))
fab.dispatchEvent(pointerEvent('pointermove', { pointerId: 2, clientX: 300, clientY: 500 }))
await nextTick()
expect(fab.classList.contains('dragging')).toBe(false)
expect(fab.getAttribute('style')).toBeNull()
})
it('snaps without animation when reduced motion is requested', async () => {
const { button } = await mountButton({ reducedMotion: true })
const fab = button()!
setRect(fab, 250, 300)
fab.dispatchEvent(pointerEvent('pointerdown', { clientX: 260, clientY: 310 }))
fab.dispatchEvent(pointerEvent('pointerup', { clientX: 280, clientY: 310 }))
await nextTick()
expect(fab.style.left).toBe('320px')
expect(fab.style.top).toBe('300px')
expect(fab.classList.contains('snapping')).toBe(false)
})
it('re-snaps a placed button after the viewport is resized', async () => {
const { button } = await mountButton()
const fab = button()!
setRect(fab, 250, 300)
fab.dispatchEvent(pointerEvent('pointerdown', { clientX: 260, clientY: 310 }))
fab.dispatchEvent(pointerEvent('pointerup', { clientX: 280, clientY: 310 }))
await nextTick()
expect(fab.style.left).toBe('320px')
vi.stubGlobal('innerWidth', 300)
window.dispatchEvent(new Event('resize'))
await nextTick()
expect(fab.style.left).toBe('230px')
expect(fab.style.top).toBe('300px')
})
})
+530
View File
@@ -0,0 +1,530 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from backend import recurrence_service
from backend.db import Base
from backend.models import RecurrenceTemplate, Task, TaskList, User
NOW = datetime(2026, 9, 19, 8, 0, tzinfo=UTC)
DUE = datetime(2026, 9, 20, 9, 30, tzinfo=UTC)
NEXT_DUE = datetime(2026, 9, 21, 9, 30, tzinfo=UTC)
def make_user(*, timezone="Asia/Shanghai"):
return User(id=uuid4(), username="owner", password_hash="hash", timezone=timezone)
def make_task(user, **overrides):
values = {
"id": uuid4(),
"user_id": user.id,
"list_id": uuid4(),
"title": "task",
"completed": False,
"due_at": DUE,
"due_has_time": True,
"version": 3,
}
values.update(overrides)
return Task(**values)
def make_recurrence(user, task, **overrides):
values = {
"id": uuid4(),
"user_id": user.id,
"task_id": task.id,
"rrule": "FREQ=DAILY",
"starts_at": DUE,
"ends_at": None,
"trigger_mode": "scheduled",
}
values.update(overrides)
return RecurrenceTemplate(**values)
def result_returning(task):
return SimpleNamespace(scalar_one_or_none=lambda: task)
@pytest.fixture
async def sqlite_db(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'recurrence.db'}")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as db:
yield db
await engine.dispose()
async def persist_user_and_list(db, username):
user = User(username=username, password_hash="hash", timezone="Asia/Shanghai")
db.add(user)
await db.flush()
task_list = TaskList(user_id=user.id, name=f"{username} inbox", is_inbox=True)
db.add(task_list)
await db.flush()
return user, task_list
@pytest.mark.asyncio
async def test_lock_task_filters_owner_and_soft_deleted_tasks(sqlite_db):
owner, owner_list = await persist_user_and_list(sqlite_db, "owner")
foreign, foreign_list = await persist_user_and_list(sqlite_db, "foreign")
active = Task(user_id=owner.id, list_id=owner_list.id, title="active")
deleted = Task(
user_id=owner.id,
list_id=owner_list.id,
title="deleted",
deleted_at=NOW,
)
foreign_task = Task(user_id=foreign.id, list_id=foreign_list.id, title="foreign")
sqlite_db.add_all([active, deleted, foreign_task])
await sqlite_db.commit()
assert await recurrence_service.lock_task(sqlite_db, owner.id, active.id) is active
assert await recurrence_service.lock_task(sqlite_db, foreign.id, active.id) is None
assert await recurrence_service.lock_task(sqlite_db, owner.id, foreign_task.id) is None
assert await recurrence_service.lock_task(sqlite_db, owner.id, deleted.id) is None
@pytest.mark.asyncio
async def test_lock_task_actual_query_compiles_with_for_update():
class CapturingDb:
statement = None
async def scalar(self, statement):
self.statement = statement
db = CapturingDb()
await recurrence_service.lock_task(db, uuid4(), uuid4())
statement = db.statement
assert statement is not None
sql = str(statement.compile(dialect=postgresql.dialect()))
assert "tasks.user_id" in sql
assert "tasks.deleted_at IS NULL" in sql
assert sql.endswith(" FOR UPDATE")
@pytest.mark.asyncio
async def test_apply_task_changes_stale_version_cannot_update_database(sqlite_db):
owner, owner_list = await persist_user_and_list(sqlite_db, "owner")
task = Task(
user_id=owner.id,
list_id=owner_list.id,
title="original",
version=3,
)
sqlite_db.add(task)
await sqlite_db.commit()
task_id = task.id
with pytest.raises(HTTPException) as exc_info:
await recurrence_service.apply_task_changes(
sqlite_db,
user=owner,
task_id=task_id,
expected_version=2,
changes={"title": "stale write"},
)
await sqlite_db.rollback()
sqlite_db.expire_all()
persisted = await sqlite_db.get(Task, task_id)
assert exc_info.value.status_code == 409
assert persisted.title == "original"
assert persisted.version == 3
@pytest.mark.asyncio
async def test_optimistic_update_version_predicate_rejects_race(sqlite_db, monkeypatch):
owner, owner_list = await persist_user_and_list(sqlite_db, "owner")
task = Task(user_id=owner.id, list_id=owner_list.id, title="original", version=3)
sqlite_db.add(task)
await sqlite_db.commit()
task_id = task.id
original_execute = sqlite_db.execute
raced = False
async def execute_after_concurrent_version_change(statement, *args, **kwargs):
nonlocal raced
if not raced and getattr(statement, "is_update", False):
raced = True
await original_execute(
update(Task).where(Task.id == task_id).values(title="racer", version=4)
)
await sqlite_db.flush()
return await original_execute(statement, *args, **kwargs)
monkeypatch.setattr(sqlite_db, "execute", execute_after_concurrent_version_change)
with pytest.raises(HTTPException) as exc_info:
await recurrence_service.apply_task_changes(
sqlite_db,
user=owner,
task_id=task_id,
expected_version=3,
changes={"title": "loser"},
)
assert raced is True
assert exc_info.value.status_code == 409
sqlite_db.expire_all()
persisted = await sqlite_db.get(Task, task_id)
assert persisted.title == "racer"
assert persisted.version == 4
@pytest.mark.asyncio
async def test_recurring_completion_resets_only_completed_active_owned_subtasks(
sqlite_db, monkeypatch
):
owner, owner_list = await persist_user_and_list(sqlite_db, "owner")
foreign, foreign_list = await persist_user_and_list(sqlite_db, "foreign")
parent = Task(
user_id=owner.id,
list_id=owner_list.id,
title="recurring parent",
due_at=DUE,
due_has_time=True,
version=3,
)
other_parent = Task(user_id=owner.id, list_id=owner_list.id, title="other parent")
sqlite_db.add_all([parent, other_parent])
await sqlite_db.flush()
recurrence = make_recurrence(owner, parent)
target = Task(
user_id=owner.id,
list_id=owner_list.id,
parent_id=parent.id,
title="target",
completed=True,
completed_at=NOW,
version=2,
)
incomplete = Task(
user_id=owner.id,
list_id=owner_list.id,
parent_id=parent.id,
title="already incomplete",
completed=False,
version=2,
)
deleted = Task(
user_id=owner.id,
list_id=owner_list.id,
parent_id=parent.id,
title="deleted child",
completed=True,
completed_at=NOW,
deleted_at=NOW,
version=2,
)
other_parent_child = Task(
user_id=owner.id,
list_id=owner_list.id,
parent_id=other_parent.id,
title="other parent child",
completed=True,
completed_at=NOW,
version=2,
)
foreign_child = Task(
user_id=foreign.id,
list_id=foreign_list.id,
parent_id=parent.id,
title="foreign child",
completed=True,
completed_at=NOW,
version=2,
)
sqlite_db.add_all(
[recurrence, target, incomplete, deleted, other_parent_child, foreign_child]
)
await sqlite_db.commit()
parent_id = parent.id
child_ids = [
target.id,
incomplete.id,
deleted.id,
other_parent_child.id,
foreign_child.id,
]
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
monkeypatch.setattr(recurrence_service, "occurrences", lambda *args: [NEXT_DUE])
_, changed_fields = await recurrence_service.apply_task_changes(
sqlite_db,
user=owner,
task_id=parent_id,
expected_version=3,
changes={"completed": True},
)
await sqlite_db.commit()
sqlite_db.expire_all()
rows = {
row.title: row
for row in (
await sqlite_db.scalars(select(Task).where(Task.id.in_(child_ids)))
).all()
}
persisted_parent = await sqlite_db.get(Task, parent_id)
assert persisted_parent.completed is False
assert persisted_parent.due_at == NEXT_DUE
assert changed_fields == {"completed"}
assert (rows["target"].completed, rows["target"].completed_at, rows["target"].version) == (
False,
None,
3,
)
for title in ("deleted child", "other parent child", "foreign child"):
assert rows[title].completed is True
assert rows[title].completed_at == NOW
assert rows[title].version == 2
assert rows["already incomplete"].completed is False
assert rows["already incomplete"].version == 2
@pytest.mark.parametrize("timezone", ["Not/A_Zone", ""])
def test_user_zone_rejects_invalid_timezone(timezone):
with pytest.raises(HTTPException) as exc_info:
recurrence_service._user_zone(make_user(timezone=timezone))
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "用户时区无效"
@pytest.mark.asyncio
async def test_apply_task_changes_returns_not_found_when_lock_finds_no_task():
db = AsyncMock()
db.scalar.return_value = None
user = make_user()
with pytest.raises(HTTPException) as exc_info:
await recurrence_service.apply_task_changes(
db, user=user, task_id=uuid4(), expected_version=1, changes={"title": "new"}
)
assert exc_info.value.status_code == 404
assert db.scalar.await_count == 1
db.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_apply_task_changes_rejects_stale_version_before_loading_recurrence():
user = make_user()
task = make_task(user)
db = AsyncMock()
db.scalar.return_value = task
with pytest.raises(HTTPException) as exc_info:
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=2, changes={"title": "new"}
)
assert exc_info.value.status_code == 409
assert db.scalar.await_count == 1
db.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_plain_completion_sets_timestamp_without_resetting_subtasks(monkeypatch):
user = make_user()
task = make_task(user)
updated = make_task(user, id=task.id, completed=True, version=4, completed_at=NOW)
db = AsyncMock()
db.scalar.side_effect = [task, None]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
changes = {"completed": True}
returned, changed_fields = await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes=changes
)
assert returned is updated
assert changed_fields == {"completed"}
assert changes["completed_at"] == NOW
assert db.execute.await_count == 1
@pytest.mark.asyncio
async def test_uncompleting_task_clears_completed_timestamp(monkeypatch):
user = make_user()
task = make_task(user, completed=True, completed_at=NOW)
updated = make_task(user, id=task.id, completed=False, version=4, completed_at=None)
db = AsyncMock()
db.scalar.side_effect = [task, None]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
changes = {"completed": False}
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes=changes
)
assert changes["completed_at"] is None
@pytest.mark.asyncio
async def test_after_completion_reschedules_from_completion_and_records_it(monkeypatch):
user = make_user()
task = make_task(user)
recurrence = make_recurrence(
user,
task,
rrule=None,
trigger_mode="after_completion",
after_completion_days=2,
)
updated = make_task(user, id=task.id, due_at=NEXT_DUE, version=4)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.side_effect = [result_returning(updated), SimpleNamespace()]
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
monkeypatch.setattr(recurrence_service, "_after_completion_due", lambda *args: NEXT_DUE)
changes = {"completed": True}
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes=changes
)
assert changes == {"completed": False, "due_at": NEXT_DUE, "completed_at": None}
assert recurrence.starts_at == NEXT_DUE
assert recurrence.last_completed_at == NOW
assert db.execute.await_count == 2
@pytest.mark.asyncio
async def test_scheduled_completion_advances_due_and_resets_completed_subtasks(monkeypatch):
user = make_user()
task = make_task(user)
recurrence = make_recurrence(user, task)
updated = make_task(user, id=task.id, due_at=NEXT_DUE, version=4)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.side_effect = [result_returning(updated), SimpleNamespace()]
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
monkeypatch.setattr(recurrence_service, "occurrences", lambda *args: [NEXT_DUE])
changes = {"completed": True}
returned, changed_fields = await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes=changes
)
assert returned is updated
assert changed_fields == {"completed"}
assert changes == {"completed": False, "due_at": NEXT_DUE, "completed_at": None}
assert recurrence.starts_at == NEXT_DUE
assert db.execute.await_count == 2
@pytest.mark.asyncio
async def test_scheduled_completion_stays_completed_when_rule_has_no_future_occurrence(monkeypatch):
user = make_user()
task = make_task(user)
recurrence = make_recurrence(user, task)
updated = make_task(user, id=task.id, completed=True, version=4, completed_at=NOW)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
monkeypatch.setattr(recurrence_service, "occurrences", lambda *args: [])
changes = {"completed": True}
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes=changes
)
assert changes == {"completed": True, "completed_at": NOW}
assert recurrence.starts_at == DUE
assert db.execute.await_count == 1
@pytest.mark.asyncio
async def test_removing_due_date_deletes_recurrence(monkeypatch):
user = make_user()
task = make_task(user)
recurrence = make_recurrence(user, task)
updated = make_task(user, id=task.id, due_at=None, version=4)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes={"due_at": None}
)
db.delete.assert_awaited_once_with(recurrence)
@pytest.mark.asyncio
async def test_moving_due_date_keeps_recurrence_anchor_in_sync(monkeypatch):
user = make_user()
task = make_task(user)
recurrence = make_recurrence(user, task)
updated = make_task(user, id=task.id, due_at=NEXT_DUE, version=4)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes={"due_at": NEXT_DUE}
)
assert recurrence.starts_at == NEXT_DUE
db.delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_completion_request_with_explicit_due_does_not_reanchor_recurrence(monkeypatch):
user = make_user()
task = make_task(user, completed=True, completed_at=NOW)
recurrence = make_recurrence(user, task)
updated = make_task(user, id=task.id, completed=True, due_at=NEXT_DUE, version=4)
db = AsyncMock()
db.scalar.side_effect = [task, recurrence]
db.execute.return_value = result_returning(updated)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
await recurrence_service.apply_task_changes(
db,
user=user,
task_id=task.id,
expected_version=3,
changes={"completed": True, "due_at": NEXT_DUE},
)
assert recurrence.starts_at == DUE
@pytest.mark.asyncio
async def test_concurrent_update_loss_returns_conflict(monkeypatch):
user = make_user()
task = make_task(user)
db = AsyncMock()
db.scalar.side_effect = [task, None]
db.execute.return_value = result_returning(None)
monkeypatch.setattr(recurrence_service, "utcnow", lambda: NOW)
with pytest.raises(HTTPException) as exc_info:
await recurrence_service.apply_task_changes(
db, user=user, task_id=task.id, expected_version=3, changes={"title": "new"}
)
assert exc_info.value.status_code == 409