feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+75 -48
View File
@@ -99,10 +99,19 @@ describe('mobile navigation styles', () => {
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
})
it('keeps the mobile More sheet visible when it is rendered', () => {
expect(css).not.toContain('.more-mask{display:none}')
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).toContain('.more-mask{position:fixed;z-index:45;')
it('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', () => {
@@ -125,7 +134,7 @@ describe('settings sessions and audit activity', () => {
expect(mvpPanel).toContain('class="session-card-actions"')
expect(mvpPanel).toContain('撤销其他所有会话')
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')")
expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
expect(css).toContain('.session-card-actions{')
})
@@ -186,10 +195,11 @@ describe('solid cream material system', () => {
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
expect(css).toContain('main{background:var(--surface-base)}')
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{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('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
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', () => {
@@ -409,14 +419,19 @@ describe('archived task-list disclosure', () => {
})
describe('mobile sheet contract', () => {
it('uses shared roles for details, creation and secondary actions', () => {
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
it('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
expect(css).toContain('.app-overlay>.detail{transform:none}')
})
it('uses shared AppSheet variants for details and creation', () => {
expect(app).toContain('panel-class="task-compose-sheet"')
expect(app).toContain('variant="create"')
expect(app).not.toContain('class="more-sheet app-sheet app-sheet--actions"')
expect(mvpPanel).toContain('panel-class="task-compose-sheet habit-compose-sheet"')
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
expect(countdownPanel).toContain('panel-class="countdown-modal"')
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
expect(css).toContain('.app-sheet__header{min-height:64px;')
@@ -447,8 +462,7 @@ describe('mobile list row language', () => {
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
@@ -466,8 +480,8 @@ describe('mobile list row language', () => {
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
expect(mvpPanel).not.toContain('habitDetailSheet')
})
it('provides an archived-habit viewing path', () => {
@@ -927,7 +941,7 @@ describe('task and habit row decoration', () => {
})
it('shows a password form with confirmation and calls the protected endpoint', () => {
expect(mvpPanel).toContain('class="password-form"')
expect(mvpPanel).toContain('class="password-form settings-form"')
expect(mvpPanel).toContain('aria-label="当前密码"')
expect(mvpPanel).toContain('aria-label="新密码"')
expect(mvpPanel).toContain('aria-label="确认新密码"')
@@ -972,20 +986,22 @@ describe('mobile touch targets', () => {
})
describe('approved habit safety and U2 title hierarchy', () => {
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
it('uses one page title and continuous settings section headings', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain('<h2>修改密码</h2>')
expect(mvpPanel).toContain('<h2>登录会话</h2>')
expect(mvpPanel).toContain('<h2>最近活动</h2>')
expect(css).toContain('.tool-card>h2{')
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
expect(mvpPanel).toContain('class="settings-sections"')
expect(mvpPanel).not.toContain('class="settings-grid"')
expect(mvpPanel).not.toContain('class="tool-card')
expect(css).toContain('.settings-sections{width:min(100%,760px);')
expect(css).toContain('.settings-row{min-height:56px;')
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const modalError = ref')
expect(app).toContain('role="alert" class="field-error"')
expect(app).toContain('normalizeRequiredName')
expect(app).toContain('const appDialog = ref')
expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
expect(mvpPanel).toContain('habitErrors.name')
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
expect(mvpPanel).toContain('const habitFormInvalid = computed')
@@ -1009,17 +1025,26 @@ describe('approved habit safety and U2 title hierarchy', () => {
})
describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV')
expect(mvpPanel).not.toContain('导出 JSON')
expect(mvpPanel).toContain('@click="restore"')
expect(mvpPanel).not.toContain('<h3>导入</h3>')
it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
expect(mvpPanel).toContain('<h2>数据</h2>')
expect(mvpPanel).toContain("downloadFullBackup()")
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
expect(mvpPanel).toContain('导出 ZIP')
expect(mvpPanel).toContain('accept=".zip,.csv,.json')
expect(mvpPanel).toContain('v-model="restoreMode"')
expect(mvpPanel).toContain('runPreflight')
expect(mvpPanel).toContain('restorePreflight.valid')
expect(mvpPanel).toContain('preflight_token')
expect(mvpPanel).toContain('await restoreBackup')
expect(mvpPanel).toContain("await uploadJson('/restore.csv?mode=merge'")
expect(mvpPanel).toContain("await requestJson('/restore?mode=merge'")
expect(mvpPanel).toContain('const snapshot = backupFileSnapshot(file, restoreMode.value)')
expect(mvpPanel).toContain('shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)')
expect(mvpPanel).toContain('preflightController?.abort()')
expect(mvpPanel).toContain("旧格式将在恢复时校验,不支持完整预检或 Replace。")
expect(mvpPanel).toContain('<option v-if="!legacyRestore" value="replace">')
expect(mvpPanel).toContain("emit('changed'); emit('notice', '数据已恢复')")
expect(mvpPanel).not.toContain("request('/import/ticktick")
expect(mvpPanel).not.toContain('importFile')
expect(mvpPanel).not.toContain('importPreview')
})
})
@@ -1132,8 +1157,8 @@ describe('unified floating add interaction', () => {
expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging')
expect(css).toContain('.unified-fab.snapping')
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
expect(css).toContain('.countdown-compose-enter-active')
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
expect(countdownPanel).toContain('panel-class="countdown-modal"')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
})
@@ -1169,7 +1194,8 @@ describe('unified floating add interaction', () => {
describe('desktop task detail disclosure', () => {
it('gives the task list the full remaining width until a task is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask)")
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
expect(app).toContain('panel-class="detail"')
expect(app).toContain('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()')
expect(app).not.toContain('<div v-else class="paper">')
@@ -1233,8 +1259,8 @@ describe('sidebar information hierarchy', () => {
it('groups folder and list editing actions into a clear compact hierarchy', () => {
expect(app).toContain('aria-label="打开文件夹操作"')
expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
expect(app).toContain('panel-class="sidebar-action-sheet"')
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
expect(app).toContain('class="sidebar-action-kind"')
expect(app).toContain('class="sidebar-action-group"')
expect(app).toContain('class="sidebar-action-group-title"')
@@ -1382,10 +1408,11 @@ describe('sidebar layout', () => {
it('uses a guarded custom confirmation that keeps failures visible', () => {
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
expect(app).toContain('ref="purgeCancelButton"')
expect(app).toContain('purgeCancelButton.value?.focus()')
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
expect(app).toContain('panel-class="purge-list-dialog"')
expect(app).toContain('initial-focus=".secondary"')
expect(app).toContain(':busy="purgeListSubmitting"')
expect(app).toContain('@close="closePurgeList"')
expect(app).not.toContain('handlePurgeDialogKeydown')
expect(app).toContain('if (purgeListSubmitting.value) return')
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
expect(app).toContain(':disabled="purgeListSubmitting"')