fix(history): require confirmation before clearing all history
This commit is contained in:
parent
819bc9d789
commit
a8983c583a
5 changed files with 441 additions and 13 deletions
|
|
@ -10,6 +10,9 @@ import { useI18n } from '@d3ro/i18n'
|
||||||
import { getDateKey } from '../utils/formatters'
|
import { getDateKey } from '../utils/formatters'
|
||||||
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
|
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
|
||||||
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@d3ro/core/types'
|
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@d3ro/core/types'
|
||||||
|
import { isClearAllDialogOpen, isHistoryViewFiltered } from './history/clear-all-history-flow'
|
||||||
|
import { useClearAllHistory } from './history/useClearAllHistory'
|
||||||
|
import { ClearAllHistoryDialog } from './history/ClearAllHistoryDialog'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
@ -69,7 +72,6 @@ export function HistoryPage(): React.ReactElement {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isExportingRef = useRef(false)
|
const isExportingRef = useRef(false)
|
||||||
const isDeletingAllRef = useRef(false)
|
|
||||||
const isDeletingSingleRef = useRef(false)
|
const isDeletingSingleRef = useRef(false)
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
|
|
@ -117,16 +119,9 @@ export function HistoryPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
}, [activeTag])
|
}, [activeTag])
|
||||||
|
|
||||||
const handleDeleteAll = useCallback(async () => {
|
// 전체 삭제는 필터와 무관하고 동기화로 다른 기기에도 전파되므로 반드시 확인 단계를 거친다
|
||||||
if (isDeletingAllRef.current) return
|
const clearAll = useClearAllHistory(loadData)
|
||||||
isDeletingAllRef.current = true
|
const clearAllState = clearAll.state
|
||||||
try {
|
|
||||||
await window.electronAPI.history.deleteAll()
|
|
||||||
loadData()
|
|
||||||
} finally {
|
|
||||||
isDeletingAllRef.current = false
|
|
||||||
}
|
|
||||||
}, [loadData])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
|
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
|
||||||
|
|
@ -143,11 +138,13 @@ export function HistoryPage(): React.ReactElement {
|
||||||
</PhysicalButton>
|
</PhysicalButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip title={t('common.clearAll') || '모두 지우기'} arrow>
|
<Tooltip title={t('history.clearAll')} arrow>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
data-testid="clear-all-history-button"
|
data-testid="clear-all-history-button"
|
||||||
onClick={handleDeleteAll}
|
aria-label={t('history.clearAll')}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={clearAll.request}
|
||||||
sx={{
|
sx={{
|
||||||
color: d3roPalette.text.inactive,
|
color: d3roPalette.text.inactive,
|
||||||
p: 0.75,
|
p: 0.75,
|
||||||
|
|
@ -163,6 +160,15 @@ export function HistoryPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ClearAllHistoryDialog
|
||||||
|
open={isClearAllDialogOpen(clearAllState)}
|
||||||
|
count={'count' in clearAllState ? clearAllState.count : 0}
|
||||||
|
filtered={isHistoryViewFiltered(activeTag, search)}
|
||||||
|
deleting={clearAllState.phase === 'deleting'}
|
||||||
|
onCancel={clearAll.cancel}
|
||||||
|
onConfirm={clearAll.confirm}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Search Input Bar */}
|
{/* Search Input Bar */}
|
||||||
{!activeTag && (
|
{!activeTag && (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
// src/renderer/pages/history/ClearAllHistoryDialog.tsx
|
||||||
|
// 히스토리 전체 삭제 확인 다이얼로그 — 삭제 건수, 다른 기기 전파, 필터 무시 여부를 알린다.
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@mui/material'
|
||||||
|
import { PhosphorText, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||||
|
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||||
|
import { useI18n } from '@d3ro/i18n'
|
||||||
|
|
||||||
|
export interface ClearAllHistoryDialogProps {
|
||||||
|
open: boolean
|
||||||
|
/** 필터와 무관한 전체 삭제 대상 건수 */
|
||||||
|
count: number
|
||||||
|
/** 화면이 태그·검색으로 좁혀져 있으면 '보이는 것만 지워지지 않는다'를 알린다 */
|
||||||
|
filtered: boolean
|
||||||
|
deleting: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearAllHistoryDialog({
|
||||||
|
open,
|
||||||
|
count,
|
||||||
|
filtered,
|
||||||
|
deleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: ClearAllHistoryDialogProps): React.ReactElement {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={deleting ? undefined : onCancel}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
data-testid="clear-all-history-dialog"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
bgcolor: d3roPalette.bg.card,
|
||||||
|
border: `1px solid ${d3roPalette.border.default}`,
|
||||||
|
borderRadius: d3roRadius.inner,
|
||||||
|
boxShadow: d3roShadow.tooltip,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<PhosphorText variant="label">{t('history.clearAll')}</PhosphorText>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||||
|
<PhosphorText variant="body" component="p">
|
||||||
|
{t('history.clearAllConfirm', { count })}
|
||||||
|
</PhosphorText>
|
||||||
|
{filtered && (
|
||||||
|
<PhosphorText
|
||||||
|
variant="body"
|
||||||
|
component="p"
|
||||||
|
data-testid="clear-all-history-filter-warning"
|
||||||
|
sx={{ color: d3roPalette.status.warning }}
|
||||||
|
>
|
||||||
|
{t('history.clearAllIgnoresFilter')}
|
||||||
|
</PhosphorText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||||
|
<PhysicalButton
|
||||||
|
size="small"
|
||||||
|
data-testid="clear-all-history-cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={deleting}
|
||||||
|
autoFocus
|
||||||
|
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</PhysicalButton>
|
||||||
|
<PhysicalButton
|
||||||
|
size="small"
|
||||||
|
data-testid="clear-all-history-confirm"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={deleting}
|
||||||
|
sx={{ height: 36, fontSize: d3roTypo.engrave.size, color: d3roPalette.status.danger }}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</PhysicalButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// src/renderer/pages/history/clear-all-history-flow.ts
|
||||||
|
// 히스토리 '전체 삭제' 흐름 — 확인 단계를 거쳐야만 삭제가 실행되도록 강제하는 순수 상태 머신.
|
||||||
|
//
|
||||||
|
// 전체 삭제는 필터·검색과 무관하게 모든 기록을 지우고, 클라우드 동기화 중이면 다른 기기로도
|
||||||
|
// 전파된다(HistoryService.deleteAll → sync.pushDelete). 되돌릴 수 없으므로 한 번의 클릭으로
|
||||||
|
// 실행되면 안 된다. 이 모듈은 React·Electron에 의존하지 않으며 IO는 포트로 주입받는다.
|
||||||
|
|
||||||
|
/** 전체 삭제 흐름이 필요로 하는 IO 포트 (DIP: UI는 IPC 세부를 모른다) */
|
||||||
|
export interface ClearAllHistoryPort {
|
||||||
|
/** 필터와 무관한 전체 기록 수. 조회에 실패하면 null */
|
||||||
|
countAll(): Promise<number | null>
|
||||||
|
/** 전체 기록을 삭제한다. 성공 여부를 돌려준다 */
|
||||||
|
deleteAll(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClearAllHistoryState =
|
||||||
|
| { readonly phase: 'idle' }
|
||||||
|
| { readonly phase: 'counting' }
|
||||||
|
| { readonly phase: 'confirming'; readonly count: number }
|
||||||
|
| { readonly phase: 'deleting'; readonly count: number }
|
||||||
|
|
||||||
|
export type ClearAllHistoryListener = (state: ClearAllHistoryState) => void
|
||||||
|
|
||||||
|
export const CLEAR_ALL_IDLE: ClearAllHistoryState = { phase: 'idle' }
|
||||||
|
|
||||||
|
/** 현재 화면이 태그 필터나 검색으로 좁혀져 있는지 — 전체 삭제가 '보이는 것만'이 아님을 알려야 하는 경우 */
|
||||||
|
export function isHistoryViewFiltered(activeTag: string | null, search: string): boolean {
|
||||||
|
return activeTag !== null || search.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 다이얼로그를 띄워야 하는 상태인지 */
|
||||||
|
export function isClearAllDialogOpen(state: ClearAllHistoryState): boolean {
|
||||||
|
return state.phase === 'confirming' || state.phase === 'deleting'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* idle → (request) → counting → confirming(count) → (confirm) → deleting → idle
|
||||||
|
* └──── (cancel) ────────────→ idle
|
||||||
|
*
|
||||||
|
* - deleteAll은 confirming 상태에서 confirm()이 호출될 때만, 정확히 한 번 실행된다.
|
||||||
|
* - 삭제할 기록이 없거나 개수 조회에 실패하면 확인 단계로 가지 않고 idle로 돌아간다.
|
||||||
|
* - 개수 조회 중에 취소되면 늦게 도착한 결과는 버린다.
|
||||||
|
*/
|
||||||
|
export class ClearAllHistoryFlow {
|
||||||
|
private state: ClearAllHistoryState = CLEAR_ALL_IDLE
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly port: ClearAllHistoryPort,
|
||||||
|
private readonly onChange: ClearAllHistoryListener = () => undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getState(): ClearAllHistoryState {
|
||||||
|
return this.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 삭제 요청 — 실제 삭제는 하지 않고 확인 단계로만 이동한다 */
|
||||||
|
async request(): Promise<void> {
|
||||||
|
if (this.state.phase !== 'idle') return
|
||||||
|
const generation = ++this.generation
|
||||||
|
this.set({ phase: 'counting' })
|
||||||
|
|
||||||
|
let count: number | null
|
||||||
|
try {
|
||||||
|
count = await this.port.countAll()
|
||||||
|
} catch {
|
||||||
|
count = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation !== this.generation) return
|
||||||
|
if (count === null || count <= 0) {
|
||||||
|
this.set(CLEAR_ALL_IDLE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.set({ phase: 'confirming', count })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확인 단계를 닫는다. 삭제 진행 중에는 취소할 수 없다 */
|
||||||
|
cancel(): void {
|
||||||
|
if (this.state.phase !== 'counting' && this.state.phase !== 'confirming') return
|
||||||
|
this.generation++
|
||||||
|
this.set(CLEAR_ALL_IDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사용자가 확인했을 때만 삭제를 실행한다. 삭제가 실행되어 성공하면 true */
|
||||||
|
async confirm(): Promise<boolean> {
|
||||||
|
if (this.state.phase !== 'confirming') return false
|
||||||
|
this.set({ phase: 'deleting', count: this.state.count })
|
||||||
|
try {
|
||||||
|
return await this.port.deleteAll()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
this.generation++
|
||||||
|
this.set(CLEAR_ALL_IDLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private set(next: ClearAllHistoryState): void {
|
||||||
|
this.state = next
|
||||||
|
this.onChange(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
// src/renderer/pages/history/useClearAllHistory.ts
|
||||||
|
// ClearAllHistoryFlow를 React 상태에 연결하고, Electron IPC를 포트로 어댑팅한다.
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CLEAR_ALL_IDLE,
|
||||||
|
ClearAllHistoryFlow,
|
||||||
|
type ClearAllHistoryPort,
|
||||||
|
type ClearAllHistoryState,
|
||||||
|
} from './clear-all-history-flow'
|
||||||
|
|
||||||
|
/** preload history API → ClearAllHistoryPort 어댑터 */
|
||||||
|
export function createElectronClearAllHistoryPort(): ClearAllHistoryPort {
|
||||||
|
return {
|
||||||
|
async countAll() {
|
||||||
|
const result = await window.electronAPI.history.getAll({ page: 0, pageSize: 1, sortOrder: 'desc' })
|
||||||
|
return result.success ? result.data.total : null
|
||||||
|
},
|
||||||
|
async deleteAll() {
|
||||||
|
const result = await window.electronAPI.history.deleteAll()
|
||||||
|
return result.success
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClearAllHistoryControls {
|
||||||
|
state: ClearAllHistoryState
|
||||||
|
request: () => void
|
||||||
|
confirm: () => void
|
||||||
|
cancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onSettled 삭제가 실행된 뒤(성공·실패 무관) 목록을 다시 불러오기 위한 콜백
|
||||||
|
* @param port 테스트·다른 호스트용 포트 주입 지점. 기본값은 Electron IPC
|
||||||
|
*/
|
||||||
|
export function useClearAllHistory(
|
||||||
|
onSettled: () => void,
|
||||||
|
port?: ClearAllHistoryPort,
|
||||||
|
): ClearAllHistoryControls {
|
||||||
|
const [state, setState] = useState<ClearAllHistoryState>(CLEAR_ALL_IDLE)
|
||||||
|
const onSettledRef = useRef(onSettled)
|
||||||
|
onSettledRef.current = onSettled
|
||||||
|
|
||||||
|
const flowRef = useRef<ClearAllHistoryFlow | null>(null)
|
||||||
|
if (flowRef.current === null) {
|
||||||
|
flowRef.current = new ClearAllHistoryFlow(port ?? createElectronClearAllHistoryPort(), setState)
|
||||||
|
}
|
||||||
|
const flow = flowRef.current
|
||||||
|
|
||||||
|
const request = useCallback(() => {
|
||||||
|
void flow.request()
|
||||||
|
}, [flow])
|
||||||
|
|
||||||
|
const confirm = useCallback(() => {
|
||||||
|
if (flow.getState().phase !== 'confirming') return
|
||||||
|
void flow.confirm().finally(() => onSettledRef.current())
|
||||||
|
}, [flow])
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
flow.cancel()
|
||||||
|
}, [flow])
|
||||||
|
|
||||||
|
return { state, request, confirm, cancel }
|
||||||
|
}
|
||||||
160
apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts
Normal file
160
apps/desktop/tests/unit/history-clear-all-redteam-r1-28.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// tests/unit/history-clear-all-redteam-r1-28.test.ts
|
||||||
|
// 회귀: 히스토리 '모두 지우기'가 확인 없이 한 번 클릭으로 전체 기록을 지우고 클라우드로 전파하던 결함.
|
||||||
|
// HistoryPage는 이제 ClearAllHistoryFlow를 거쳐서만 deleteAll을 호출한다.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
ClearAllHistoryFlow,
|
||||||
|
isClearAllDialogOpen,
|
||||||
|
isHistoryViewFiltered,
|
||||||
|
type ClearAllHistoryPort,
|
||||||
|
type ClearAllHistoryState,
|
||||||
|
} from '../../src/renderer/pages/history/clear-all-history-flow'
|
||||||
|
|
||||||
|
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
||||||
|
let resolve!: (v: T) => void
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePort(count: number | null = 12): ClearAllHistoryPort & {
|
||||||
|
countAll: ReturnType<typeof vi.fn>
|
||||||
|
deleteAll: ReturnType<typeof vi.fn>
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
countAll: vi.fn(async () => count),
|
||||||
|
deleteAll: vi.fn(async () => true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClearAllHistoryFlow — 확인 없이는 전체 삭제하지 않는다', () => {
|
||||||
|
it('request()만으로는 deleteAll을 호출하지 않고 전체 건수와 함께 확인 단계로 간다', async () => {
|
||||||
|
const port = makePort(12)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
await flow.request()
|
||||||
|
|
||||||
|
expect(port.deleteAll).not.toHaveBeenCalled()
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'confirming', count: 12 })
|
||||||
|
expect(isClearAllDialogOpen(flow.getState())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm() 후에만 deleteAll이 정확히 한 번 실행되고 idle로 돌아온다', async () => {
|
||||||
|
const port = makePort(3)
|
||||||
|
const states: ClearAllHistoryState[] = []
|
||||||
|
const flow = new ClearAllHistoryFlow(port, (s) => states.push(s))
|
||||||
|
|
||||||
|
await flow.request()
|
||||||
|
const ok = await flow.confirm()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(port.deleteAll).toHaveBeenCalledTimes(1)
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
expect(states.map((s) => s.phase)).toEqual(['counting', 'confirming', 'deleting', 'idle'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel()하면 아무것도 지우지 않는다', async () => {
|
||||||
|
const port = makePort(5)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
await flow.request()
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
expect(await flow.confirm()).toBe(false)
|
||||||
|
expect(port.deleteAll).not.toHaveBeenCalled()
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('확인 단계 없이 confirm()을 불러도 삭제하지 않는다', async () => {
|
||||||
|
const port = makePort(5)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
expect(await flow.confirm()).toBe(false)
|
||||||
|
expect(port.deleteAll).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirm을 연타해도 deleteAll은 한 번만 실행된다', async () => {
|
||||||
|
const port = makePort(5)
|
||||||
|
const gate = deferred<boolean>()
|
||||||
|
port.deleteAll.mockImplementation(() => gate.promise)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
await flow.request()
|
||||||
|
const first = flow.confirm()
|
||||||
|
const second = flow.confirm()
|
||||||
|
gate.resolve(true)
|
||||||
|
|
||||||
|
expect(await first).toBe(true)
|
||||||
|
expect(await second).toBe(false)
|
||||||
|
expect(port.deleteAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('지울 기록이 없거나 개수 조회에 실패하면 확인 단계로 가지 않는다', async () => {
|
||||||
|
for (const count of [0, null]) {
|
||||||
|
const port = makePort(count)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
await flow.request()
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
expect(port.deleteAll).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
const throwing = makePort(1)
|
||||||
|
throwing.countAll.mockRejectedValue(new Error('ipc down'))
|
||||||
|
const flow = new ClearAllHistoryFlow(throwing)
|
||||||
|
await flow.request()
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('개수 조회 중 취소되면 늦게 온 결과로 다이얼로그를 다시 열지 않는다', async () => {
|
||||||
|
const port = makePort()
|
||||||
|
const gate = deferred<number | null>()
|
||||||
|
port.countAll.mockImplementation(() => gate.promise)
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
const pending = flow.request()
|
||||||
|
flow.cancel()
|
||||||
|
gate.resolve(9)
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deleteAll이 실패하거나 throw해도 idle로 복귀하고 false를 돌려준다', async () => {
|
||||||
|
const port = makePort(2)
|
||||||
|
port.deleteAll.mockRejectedValue(new Error('db locked'))
|
||||||
|
const flow = new ClearAllHistoryFlow(port)
|
||||||
|
|
||||||
|
await flow.request()
|
||||||
|
expect(await flow.confirm()).toBe(false)
|
||||||
|
expect(flow.getState()).toEqual({ phase: 'idle' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isHistoryViewFiltered', () => {
|
||||||
|
it('태그 필터나 공백이 아닌 검색어가 있으면 필터된 화면이다', () => {
|
||||||
|
expect(isHistoryViewFiltered(null, '')).toBe(false)
|
||||||
|
expect(isHistoryViewFiltered(null, ' ')).toBe(false)
|
||||||
|
expect(isHistoryViewFiltered('work', '')).toBe(true)
|
||||||
|
expect(isHistoryViewFiltered(null, 'meeting')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HistoryPage 배선', () => {
|
||||||
|
const pageSource = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../src/renderer/pages/HistoryPage.tsx'),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
it('페이지가 history.deleteAll을 직접 호출하지 않는다 (확인 흐름을 우회하지 않음)', () => {
|
||||||
|
expect(pageSource).not.toMatch(/electronAPI\.history\.deleteAll\s*\(/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('휴지통 버튼은 삭제가 아니라 확인 요청만 한다', () => {
|
||||||
|
const button = pageSource.slice(pageSource.indexOf('data-testid="clear-all-history-button"'))
|
||||||
|
expect(button).toMatch(/^[^>]*onClick=\{clearAll\.request\}/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue