|
|
|
@@ -0,0 +1,824 @@
|
|
|
|
|
import type { TransferHistory } from '@/api/types'
|
|
|
|
|
import i18n from '@/plugins/i18n'
|
|
|
|
|
import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
|
|
|
|
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
|
|
|
|
import { renderWithProviders } from '@tests/support/render'
|
|
|
|
|
import { flushPromises } from '@vue/test-utils'
|
|
|
|
|
import { computed, defineComponent, h, nextTick, ref, unref, type PropType } from 'vue'
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
|
|
|
apiDelete: vi.fn(),
|
|
|
|
|
apiGet: vi.fn(),
|
|
|
|
|
apiPost: vi.fn(),
|
|
|
|
|
appMode: false,
|
|
|
|
|
desktop: true,
|
|
|
|
|
dynamicButtonConfig: undefined as Record<string, unknown> | undefined,
|
|
|
|
|
openSharedDialog: vi.fn(),
|
|
|
|
|
progressCallback: undefined as ((event: MessageEvent) => unknown) | undefined,
|
|
|
|
|
progressStart: vi.fn(),
|
|
|
|
|
progressStop: vi.fn(),
|
|
|
|
|
toastError: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@/api', () => ({
|
|
|
|
|
default: {
|
|
|
|
|
delete: (...args: unknown[]) => mocks.apiDelete(...args),
|
|
|
|
|
get: (...args: unknown[]) => mocks.apiGet(...args),
|
|
|
|
|
post: (...args: unknown[]) => mocks.apiPost(...args),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('vue-toastification', () => ({
|
|
|
|
|
useToast: () => ({ error: mocks.toastError }),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('vuetify', async importOriginal => {
|
|
|
|
|
const actual = await importOriginal<typeof import('vuetify')>()
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
useDisplay: () => ({
|
|
|
|
|
mdAndUp: computed(() => mocks.desktop),
|
|
|
|
|
smAndDown: computed(() => !mocks.desktop),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
vi.mock('@/composables/usePWA', () => ({
|
|
|
|
|
usePWA: () => ({ appMode: computed(() => mocks.appMode) }),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@/composables/useAvailableHeight', () => ({
|
|
|
|
|
useAvailableHeight: () => ({ availableHeight: ref(600) }),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@/composables/useBackground', () => ({
|
|
|
|
|
useBackground: () => ({
|
|
|
|
|
useProgressSSE: (_url: string, callback: (event: MessageEvent) => unknown) => {
|
|
|
|
|
mocks.progressCallback = callback
|
|
|
|
|
return {
|
|
|
|
|
start: mocks.progressStart,
|
|
|
|
|
stop: mocks.progressStop,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@/composables/useSharedDialog', () => ({
|
|
|
|
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@/composables/useDynamicButton', () => ({
|
|
|
|
|
useDynamicButton: (config: Record<string, unknown>) => {
|
|
|
|
|
mocks.dynamicButtonConfig = config
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const HistoryTableStub = defineComponent({
|
|
|
|
|
name: 'VDataTableVirtual',
|
|
|
|
|
props: {
|
|
|
|
|
headers: {
|
|
|
|
|
type: Array as PropType<
|
|
|
|
|
Array<{
|
|
|
|
|
key: string
|
|
|
|
|
sortRaw?: (left: TransferHistory, right: TransferHistory) => number
|
|
|
|
|
}>
|
|
|
|
|
>,
|
|
|
|
|
default: () => [],
|
|
|
|
|
},
|
|
|
|
|
items: {
|
|
|
|
|
type: Array as PropType<TransferHistory[]>,
|
|
|
|
|
default: () => [],
|
|
|
|
|
},
|
|
|
|
|
modelValue: {
|
|
|
|
|
type: Array as PropType<TransferHistory[]>,
|
|
|
|
|
default: () => [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
emits: ['update:modelValue'],
|
|
|
|
|
setup(props, { emit, slots }) {
|
|
|
|
|
return () => {
|
|
|
|
|
const sortResults =
|
|
|
|
|
props.items.length >= 2
|
|
|
|
|
? Object.fromEntries(
|
|
|
|
|
props.headers
|
|
|
|
|
.filter(header => header.sortRaw)
|
|
|
|
|
.map(header => [header.key, header.sortRaw?.(props.items[0], props.items[1])]),
|
|
|
|
|
)
|
|
|
|
|
: {}
|
|
|
|
|
|
|
|
|
|
return h('section', { 'aria-label': '整理历史桌面列表' }, [
|
|
|
|
|
h('output', { 'aria-label': '整理历史排序结果' }, JSON.stringify(sortResults)),
|
|
|
|
|
...props.items.map(item =>
|
|
|
|
|
h('article', { 'data-history-id': item.id }, [h('span', item.title), slots['item.actions']?.({ item })]),
|
|
|
|
|
),
|
|
|
|
|
h(
|
|
|
|
|
'button',
|
|
|
|
|
{
|
|
|
|
|
onClick: () => emit('update:modelValue', props.items),
|
|
|
|
|
type: 'button',
|
|
|
|
|
},
|
|
|
|
|
'选择当前页',
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
type InfiniteStatus = 'empty' | 'error' | 'ok'
|
|
|
|
|
|
|
|
|
|
const InfiniteScrollStub = defineComponent({
|
|
|
|
|
name: 'VInfiniteScroll',
|
|
|
|
|
emits: ['load'],
|
|
|
|
|
setup(_props, { emit, slots }) {
|
|
|
|
|
const status = ref('idle')
|
|
|
|
|
function load() {
|
|
|
|
|
emit('load', {
|
|
|
|
|
done(nextStatus: InfiniteStatus) {
|
|
|
|
|
status.value = nextStatus
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return () =>
|
|
|
|
|
h('section', { 'aria-label': '整理历史无限列表' }, [
|
|
|
|
|
h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
|
|
|
|
|
slots.default?.(),
|
|
|
|
|
h('button', { onClick: load, type: 'button' }, '加载下一页'),
|
|
|
|
|
])
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const ProgressiveGridStub = defineComponent({
|
|
|
|
|
name: 'ProgressiveCardGrid',
|
|
|
|
|
props: {
|
|
|
|
|
getItemKey: {
|
|
|
|
|
type: Function as PropType<(item: TransferHistory) => number>,
|
|
|
|
|
required: true,
|
|
|
|
|
},
|
|
|
|
|
items: {
|
|
|
|
|
type: Array as PropType<TransferHistory[]>,
|
|
|
|
|
default: () => [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
setup(props, { slots }) {
|
|
|
|
|
return () => {
|
|
|
|
|
const candidates =
|
|
|
|
|
props.items.length <= 7 ? props.items : [...props.items.slice(0, 3), props.items[24], ...props.items.slice(-3)]
|
|
|
|
|
const renderedItems = [...new Map(candidates.map(item => [item.id, item])).values()]
|
|
|
|
|
|
|
|
|
|
return h(
|
|
|
|
|
'section',
|
|
|
|
|
{ 'aria-label': '整理历史移动列表' },
|
|
|
|
|
renderedItems.flatMap(item => [
|
|
|
|
|
h('output', { 'data-mobile-key': props.getItemKey(item) }, String(props.getItemKey(item))),
|
|
|
|
|
...(slots.default?.({ item }) ?? []),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const SearchStub = defineComponent({
|
|
|
|
|
name: 'VCombobox',
|
|
|
|
|
props: {
|
|
|
|
|
modelValue: {
|
|
|
|
|
type: String,
|
|
|
|
|
default: '',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
emits: ['update:modelValue'],
|
|
|
|
|
setup(props, { attrs, emit }) {
|
|
|
|
|
return () =>
|
|
|
|
|
h('input', {
|
|
|
|
|
...attrs,
|
|
|
|
|
value: props.modelValue ?? '',
|
|
|
|
|
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const PassthroughStub = defineComponent({
|
|
|
|
|
inheritAttrs: false,
|
|
|
|
|
setup(_props, { attrs, slots }) {
|
|
|
|
|
return () => h('div', attrs, slots.default?.())
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const EmptyStub = defineComponent({
|
|
|
|
|
inheritAttrs: false,
|
|
|
|
|
setup() {
|
|
|
|
|
return () => h('div')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const ListItemStub = defineComponent({
|
|
|
|
|
name: 'VListItem',
|
|
|
|
|
inheritAttrs: false,
|
|
|
|
|
props: {
|
|
|
|
|
disabled: Boolean,
|
|
|
|
|
},
|
|
|
|
|
emits: ['click'],
|
|
|
|
|
setup(props, { emit, slots }) {
|
|
|
|
|
return () =>
|
|
|
|
|
h(
|
|
|
|
|
'button',
|
|
|
|
|
{
|
|
|
|
|
disabled: props.disabled,
|
|
|
|
|
onClick: () => emit('click'),
|
|
|
|
|
type: 'button',
|
|
|
|
|
},
|
|
|
|
|
[slots.prepend?.(), slots.default?.()],
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const ListItemTitleStub = defineComponent({
|
|
|
|
|
name: 'VListItemTitle',
|
|
|
|
|
inheritAttrs: false,
|
|
|
|
|
setup(_props, { attrs, slots }) {
|
|
|
|
|
return () => h('span', attrs, slots.default?.())
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function createHistory(id: number, title: string, overrides: Partial<TransferHistory> = {}): TransferHistory {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
title,
|
|
|
|
|
type: '电影',
|
|
|
|
|
status: true,
|
|
|
|
|
src: `/downloads/${title}.mkv`,
|
|
|
|
|
dest: `/media/${title}.mkv`,
|
|
|
|
|
src_storage: 'downloads',
|
|
|
|
|
dest_storage: 'library',
|
|
|
|
|
mode: 'link',
|
|
|
|
|
...overrides,
|
|
|
|
|
} as TransferHistory
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function historyResponse(list: TransferHistory[], total = list.length) {
|
|
|
|
|
return { data: { list, total }, success: true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function storageResponse() {
|
|
|
|
|
return { data: { value: [] }, success: true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createDeferred<T>() {
|
|
|
|
|
let resolve!: (value: T) => void
|
|
|
|
|
let reject!: (reason?: unknown) => void
|
|
|
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
|
|
|
resolve = resolvePromise
|
|
|
|
|
reject = rejectPromise
|
|
|
|
|
})
|
|
|
|
|
return { promise, reject, resolve }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renderHistory(initialRoute = '/history') {
|
|
|
|
|
return renderWithProviders(TransferHistoryView, {
|
|
|
|
|
global: {
|
|
|
|
|
stubs: {
|
|
|
|
|
ProgressiveCardGrid: ProgressiveGridStub,
|
|
|
|
|
VCombobox: SearchStub,
|
|
|
|
|
VDataTableVirtual: HistoryTableStub,
|
|
|
|
|
VInfiniteScroll: InfiniteScrollStub,
|
|
|
|
|
IconBtn: PassthroughStub,
|
|
|
|
|
VList: PassthroughStub,
|
|
|
|
|
VListItem: ListItemStub,
|
|
|
|
|
VListItemTitle: ListItemTitleStub,
|
|
|
|
|
VMenu: PassthroughStub,
|
|
|
|
|
VPageContentTitle: true,
|
|
|
|
|
VPagination: EmptyStub,
|
|
|
|
|
VSelect: EmptyStub,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
initialRoute,
|
|
|
|
|
initialState: {
|
|
|
|
|
globalSettings: {
|
|
|
|
|
data: {
|
|
|
|
|
AI_AGENT_ENABLE: true,
|
|
|
|
|
GLOBAL_IMAGE_CACHE: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
user: {
|
|
|
|
|
permissions: ['manage'],
|
|
|
|
|
superUser: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDynamicMenuItems() {
|
|
|
|
|
const menuItems = mocks.dynamicButtonConfig?.menuItems
|
|
|
|
|
return unref(menuItems) as
|
|
|
|
|
| Array<{
|
|
|
|
|
action: () => unknown
|
|
|
|
|
titleParams?: Record<string, unknown>
|
|
|
|
|
titleKey: string
|
|
|
|
|
}>
|
|
|
|
|
| undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runDynamicAction(titleKey: string) {
|
|
|
|
|
const item = getDynamicMenuItems()?.find(menu => menu.titleKey === titleKey)
|
|
|
|
|
if (!item) throw new Error(`未注册动态按钮操作: ${titleKey}`)
|
|
|
|
|
return item.action()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDialogCall(index = 0) {
|
|
|
|
|
const [component, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
|
|
|
|
{ __name?: string; name?: string },
|
|
|
|
|
Record<string, unknown>,
|
|
|
|
|
Record<string, (...args: unknown[]) => unknown>,
|
|
|
|
|
Record<string, unknown>,
|
|
|
|
|
]
|
|
|
|
|
return { component, events, options, props }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('TransferHistoryView', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mocks.appMode = true
|
|
|
|
|
mocks.desktop = true
|
|
|
|
|
mocks.dynamicButtonConfig = undefined
|
|
|
|
|
mocks.progressCallback = undefined
|
|
|
|
|
mocks.apiDelete.mockResolvedValue({ success: true })
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([]))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiPost.mockResolvedValue({ data: { history_ids: [1], progress_key: 'progress-1' }, success: true })
|
|
|
|
|
mocks.openSharedDialog.mockImplementation(() => ({
|
|
|
|
|
close: vi.fn(),
|
|
|
|
|
id: 1,
|
|
|
|
|
updateProps: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses the desktop URL query as the request source and falls back from invalid pagination values', async () => {
|
|
|
|
|
const requests: Array<Record<string, unknown>> = []
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: Record<string, unknown> }) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
requests.push(config?.params ?? {})
|
|
|
|
|
return Promise.resolve(historyResponse([createHistory(1, '桌面结果')], 51))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory('/history?search=%E7%A7%91%E5%B9%BB&itemsPerPage=30¤tPage=-8&grouped=true')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText('桌面结果')).toBeInTheDocument()
|
|
|
|
|
expect(requests).toEqual([{ count: 50, page: 1, title: '科幻' }])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('prevents an older desktop request from replacing a newer route search', async () => {
|
|
|
|
|
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
|
|
|
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { router } = await renderHistory('/history?search=old')
|
|
|
|
|
await router.push('/history?search=new')
|
|
|
|
|
newRequest.resolve(historyResponse([createHistory(2, '新结果')]))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText('新结果')).toBeInTheDocument()
|
|
|
|
|
oldRequest.resolve(historyResponse([createHistory(1, '旧结果')]))
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(screen.queryByText('旧结果')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('exposes title, episode, and source-size ordering through desktop table headers', async () => {
|
|
|
|
|
const histories = [
|
|
|
|
|
createHistory(1, '同名剧集', {
|
|
|
|
|
episodes: 'E02',
|
|
|
|
|
seasons: 'S01',
|
|
|
|
|
src_fileitem: { size: 100 } as TransferHistory['src_fileitem'],
|
|
|
|
|
type: '电视剧',
|
|
|
|
|
}),
|
|
|
|
|
createHistory(2, '同名剧集', {
|
|
|
|
|
episodes: 'E01',
|
|
|
|
|
seasons: 'S02',
|
|
|
|
|
src_fileitem: { size: 200 } as TransferHistory['src_fileitem'],
|
|
|
|
|
type: '电视剧',
|
|
|
|
|
}),
|
|
|
|
|
]
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse(histories))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
|
|
|
|
|
expect(await screen.findAllByText('同名剧集')).toHaveLength(2)
|
|
|
|
|
const sortResults = JSON.parse(screen.getByRole('status', { name: '整理历史排序结果' }).textContent || '{}')
|
|
|
|
|
expect(sortResults).toEqual({ size: -100, title: -1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists desktop search through the same route-backed reload contract', async () => {
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
const { router } = await renderHistory('/history')
|
|
|
|
|
await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'desktop-query')
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(router.currentRoute.value.query).toMatchObject({
|
|
|
|
|
currentPage: '1',
|
|
|
|
|
itemsPerPage: '50',
|
|
|
|
|
search: 'desktop-query',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('loads mobile pages with deduplication and reports empty when the last page is exhausted', async () => {
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
const firstPage = Array.from({ length: 25 }, (_, index) => createHistory(index + 1, `记录 ${index + 1}`))
|
|
|
|
|
const secondPage = [
|
|
|
|
|
createHistory(25, '重复记录'),
|
|
|
|
|
...Array.from({ length: 4 }, (_, index) => createHistory(26 + index, `追加 ${index + 1}`)),
|
|
|
|
|
]
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return Promise.resolve(historyResponse(historyCalls === 1 ? firstPage : secondPage, 29))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
|
|
|
|
|
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
expect(await screen.findByText('记录 25')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
expect(await screen.findByText('追加 4')).toBeInTheDocument()
|
|
|
|
|
expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
|
|
|
|
|
expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('empty')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the mobile infinite list retryable after a request error', async () => {
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
if (historyCalls === 1) return Promise.reject(new Error('temporary failure'))
|
|
|
|
|
return Promise.resolve(historyResponse([createHistory(1, '重试结果')]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
expect(await screen.findByText('重试结果')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('formats mobile cards and keeps path and batch selection interactions reversible', async () => {
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
const item = createHistory(1, '移动剧集', {
|
|
|
|
|
category: '动画',
|
|
|
|
|
date: '2025-01-02 03:04:00',
|
|
|
|
|
episodes: 'E03',
|
|
|
|
|
errmsg: '目标路径不可用',
|
|
|
|
|
image: '/poster.jpg',
|
|
|
|
|
seasons: 'S01',
|
|
|
|
|
status: false,
|
|
|
|
|
type: '电视剧',
|
|
|
|
|
year: '2025',
|
|
|
|
|
})
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { container } = await renderHistory()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText('移动剧集 S01E03')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('动画 / 2025')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('目标路径不可用')).toBeInTheDocument()
|
|
|
|
|
const pathButton = container.querySelector('.transfer-history-mobile-record__paths') as HTMLButtonElement
|
|
|
|
|
await fireEvent.click(pathButton)
|
|
|
|
|
expect(pathButton).toHaveClass('transfer-history-mobile-record__paths--expanded')
|
|
|
|
|
await fireEvent.click(pathButton)
|
|
|
|
|
expect(pathButton).not.toHaveClass('transfer-history-mobile-record__paths--expanded')
|
|
|
|
|
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '批量选择' }))
|
|
|
|
|
const record = container.querySelector('.transfer-history-mobile-record') as HTMLElement
|
|
|
|
|
await fireEvent.click(record)
|
|
|
|
|
expect(record).toHaveClass('transfer-history-mobile-record--selected')
|
|
|
|
|
runDynamicAction('transferHistory.actions.deselectAll')
|
|
|
|
|
await nextTick()
|
|
|
|
|
expect(record).not.toHaveClass('transfer-history-mobile-record--selected')
|
|
|
|
|
runDynamicAction('transferHistory.actions.exitBatchMode')
|
|
|
|
|
await nextTick()
|
|
|
|
|
expect(screen.getByRole('button', { name: '批量选择' })).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('prevents a mobile request invalidated by a route reset from appending stale records', async () => {
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
const oldRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
|
|
|
|
const newRequest = createDeferred<ReturnType<typeof historyResponse>>()
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return historyCalls === 1 ? oldRequest.promise : newRequest.promise
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { router } = await renderHistory('/history?search=old')
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
await router.push('/history?search=new')
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
newRequest.resolve(historyResponse([createHistory(2, '移动新结果')]))
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText('移动新结果')).toBeInTheDocument()
|
|
|
|
|
oldRequest.resolve(historyResponse([createHistory(1, '移动旧结果')]))
|
|
|
|
|
await flushPromises()
|
|
|
|
|
expect(screen.queryByText('移动旧结果')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists mobile search in the URL before resetting the infinite list', async () => {
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
const { router } = await renderHistory('/history?search=old')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
await fireEvent.update(screen.getByLabelText('搜索(支持 * ? 通配符)'), 'new')
|
|
|
|
|
await vi.advanceTimersByTimeAsync(600)
|
|
|
|
|
|
|
|
|
|
expect(router.currentRoute.value).toMatchObject({
|
|
|
|
|
path: '/history',
|
|
|
|
|
query: { currentPage: '1', itemsPerPage: '50', search: 'new' },
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('summarizes batch deletion failures, retains failed selections, and never renders undefined progress text', async () => {
|
|
|
|
|
const histories = [createHistory(1, '成功项'), createHistory(2, '业务失败项'), createHistory(3, '异常失败项')]
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse(histories))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiDelete
|
|
|
|
|
.mockResolvedValueOnce({ success: true })
|
|
|
|
|
.mockResolvedValueOnce({ message: '记录被占用', success: false })
|
|
|
|
|
.mockRejectedValueOnce(new Error('delete unavailable'))
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
expect(await screen.findByText('成功项')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
|
|
|
|
|
await nextTick()
|
|
|
|
|
runDynamicAction('transferHistory.actions.batchDelete')
|
|
|
|
|
|
|
|
|
|
const deleteDialog = getDialogCall()
|
|
|
|
|
await deleteDialog.events.delete(false, false)
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
const progressDialog = getDialogCall(1)
|
|
|
|
|
const controller = mocks.openSharedDialog.mock.results[1]?.value as { updateProps: ReturnType<typeof vi.fn> }
|
|
|
|
|
expect(progressDialog.component.__name || progressDialog.component.name).toContain('ProgressDialog')
|
|
|
|
|
expect(controller.updateProps).toHaveBeenCalled()
|
|
|
|
|
expect(
|
|
|
|
|
controller.updateProps.mock.calls
|
|
|
|
|
.flatMap(call => Object.values(call[0]))
|
|
|
|
|
.every(value => {
|
|
|
|
|
return typeof value !== 'string' || !value.includes('undefined')
|
|
|
|
|
}),
|
|
|
|
|
).toBe(true)
|
|
|
|
|
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('2'))
|
|
|
|
|
expect(getDynamicMenuItems()?.some(item => item.titleKey === 'transferHistory.actions.batchDelete')).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows the existing toast-style feedback when a single deletion request throws', async () => {
|
|
|
|
|
const item = createHistory(1, '异常删除')
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiDelete.mockRejectedValueOnce(new Error('delete unavailable'))
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
expect(await screen.findByText('异常删除')).toBeInTheDocument()
|
|
|
|
|
i18n.global.locale.value = 'en-US'
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
|
|
|
|
await getDialogCall().events.delete(false, false)
|
|
|
|
|
|
|
|
|
|
expect(mocks.toastError).toHaveBeenCalledWith('Failed to delete: Request failed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('releases delete-dialog ownership when either close contract fires', async () => {
|
|
|
|
|
const item = createHistory(1, '删除弹窗生命周期')
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
expect(await screen.findByText('删除弹窗生命周期')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
|
|
|
|
const firstController = mocks.openSharedDialog.mock.results[0]?.value as { close: ReturnType<typeof vi.fn> }
|
|
|
|
|
getDialogCall().events['update:modelValue'](false)
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
|
|
|
|
expect(firstController.close).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
getDialogCall(1).events.close()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
|
|
|
|
const secondController = mocks.openSharedDialog.mock.results[1]?.value as { close: ReturnType<typeof vi.fn> }
|
|
|
|
|
expect(secondController.close).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('moves back to the last available page after deleting the only record on the current page', async () => {
|
|
|
|
|
const item = createHistory(26, '第二页唯一记录')
|
|
|
|
|
const requestedPages: number[] = []
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string, config?: { params?: { page?: number } }) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
const page = config?.params?.page ?? 1
|
|
|
|
|
requestedPages.push(page)
|
|
|
|
|
if (requestedPages.length === 1) return Promise.resolve(historyResponse([item], 26))
|
|
|
|
|
return Promise.resolve(historyResponse([], 25))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { router } = await renderHistory('/history?itemsPerPage=25¤tPage=2')
|
|
|
|
|
expect(await screen.findByText('第二页唯一记录')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '删除' }))
|
|
|
|
|
await getDialogCall().events.delete(false, false)
|
|
|
|
|
await waitFor(() => expect(router.currentRoute.value.query.currentPage).toBe('1'))
|
|
|
|
|
await waitFor(() => expect(requestedPages).toEqual([2, 2, 1]))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps failed mobile batch identities selected after resetting and reloading the list', async () => {
|
|
|
|
|
mocks.desktop = false
|
|
|
|
|
const histories = [createHistory(1, '删除成功'), createHistory(2, '保留甲'), createHistory(3, '保留乙')]
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return Promise.resolve(historyResponse(historyCalls === 1 ? histories : histories.slice(1)))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiDelete
|
|
|
|
|
.mockResolvedValueOnce({ success: true })
|
|
|
|
|
.mockResolvedValueOnce({ message: 'occupied', success: false })
|
|
|
|
|
.mockRejectedValueOnce(new Error('delete unavailable'))
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
expect(await screen.findByText('删除成功')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '批量选择' }))
|
|
|
|
|
runDynamicAction('transferHistory.actions.selectAll')
|
|
|
|
|
await nextTick()
|
|
|
|
|
runDynamicAction('transferHistory.actions.batchDelete')
|
|
|
|
|
i18n.global.locale.value = 'zh-TW'
|
|
|
|
|
await getDialogCall().events.delete(false, false)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('button', { name: '退出批量選擇' })).toBeInTheDocument()
|
|
|
|
|
expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual(
|
|
|
|
|
{ count: 0, total: 0 },
|
|
|
|
|
)
|
|
|
|
|
expect(mocks.toastError).toHaveBeenCalledWith('刪除失敗:2/3')
|
|
|
|
|
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
|
|
|
|
expect(await screen.findByText('保留甲')).toBeInTheDocument()
|
|
|
|
|
expect(getDynamicMenuItems()?.find(item => item.titleKey === 'transferHistory.selectedCount')?.titleParams).toEqual(
|
|
|
|
|
{ count: 2, total: 2 },
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens the transfer queue as an isolated shared-dialog boundary', async () => {
|
|
|
|
|
await renderHistory()
|
|
|
|
|
const onClick = mocks.dynamicButtonConfig?.onClick as (() => unknown) | undefined
|
|
|
|
|
onClick?.()
|
|
|
|
|
|
|
|
|
|
const dialog = getDialogCall()
|
|
|
|
|
expect(dialog.component.__name || dialog.component.name).toContain('TransferQueueDialog')
|
|
|
|
|
expect(dialog.options).toEqual({ closeOn: ['close'] })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens the reorganize boundary with selected ids and refreshes after done', async () => {
|
|
|
|
|
const histories = [createHistory(10, '重整甲'), createHistory(11, '重整乙')]
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return Promise.resolve(historyResponse(histories))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
expect(await screen.findByText('重整甲')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
|
|
|
|
|
await nextTick()
|
|
|
|
|
runDynamicAction('transferHistory.actions.batchRedo')
|
|
|
|
|
|
|
|
|
|
const dialog = getDialogCall()
|
|
|
|
|
expect(dialog.props).toMatchObject({ logids: [10, 11] })
|
|
|
|
|
expect(dialog.options).toEqual({ closeOn: ['close', 'done'] })
|
|
|
|
|
await dialog.events.done()
|
|
|
|
|
expect(historyCalls).toBeGreaterThan(1)
|
|
|
|
|
expect(getDynamicMenuItems()).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('owns the batch AI redo SSE lifecycle through the progress boundary', async () => {
|
|
|
|
|
const histories = [createHistory(1, 'AI 重整')]
|
|
|
|
|
let historyCalls = 0
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
historyCalls += 1
|
|
|
|
|
return Promise.resolve(historyResponse(histories))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { unmount } = await renderHistory()
|
|
|
|
|
expect(await screen.findByText('AI 重整')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
|
|
|
|
|
await nextTick()
|
|
|
|
|
runDynamicAction('transferHistory.actions.batchAiRedo')
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/ai-redo', { history_ids: [1] })
|
|
|
|
|
expect(mocks.progressStart).toHaveBeenCalledOnce()
|
|
|
|
|
expect(mocks.progressCallback).toBeTypeOf('function')
|
|
|
|
|
|
|
|
|
|
await mocks.progressCallback?.(
|
|
|
|
|
new MessageEvent('message', {
|
|
|
|
|
data: JSON.stringify({ enable: true, text: '处理中' }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
const progressController = mocks.openSharedDialog.mock.results[0]?.value as {
|
|
|
|
|
close: ReturnType<typeof vi.fn>
|
|
|
|
|
updateProps: ReturnType<typeof vi.fn>
|
|
|
|
|
}
|
|
|
|
|
expect(progressController.updateProps).toHaveBeenCalledWith({ text: '处理中' })
|
|
|
|
|
|
|
|
|
|
await mocks.progressCallback?.(
|
|
|
|
|
new MessageEvent('message', {
|
|
|
|
|
data: JSON.stringify({ data: { error: 'AI 失败', success: false }, enable: false }),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
expect(mocks.progressStop).toHaveBeenCalledOnce()
|
|
|
|
|
expect(progressController.close).toHaveBeenCalledOnce()
|
|
|
|
|
expect(mocks.toastError).toHaveBeenCalledWith('AI 失败')
|
|
|
|
|
expect(historyCalls).toBeGreaterThan(1)
|
|
|
|
|
|
|
|
|
|
unmount()
|
|
|
|
|
expect(mocks.progressStop).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('starts the single AI redo progress boundary with the accepted progress key', async () => {
|
|
|
|
|
const item = createHistory(7, '单条 AI')
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiPost.mockResolvedValueOnce({ data: { progress_key: 'single-progress' }, success: true })
|
|
|
|
|
|
|
|
|
|
await renderHistory()
|
|
|
|
|
expect(await screen.findByText('单条 AI')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' }))
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(mocks.apiPost).toHaveBeenCalledWith('history/transfer/7/ai-redo')
|
|
|
|
|
expect(mocks.progressStart).toHaveBeenCalledOnce()
|
|
|
|
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not start a single AI redo progress boundary when its POST resolves after unmount', async () => {
|
|
|
|
|
const item = createHistory(1, '卸载中的单条 AI')
|
|
|
|
|
const pending = createDeferred<{ data: { progress_key: string }; success: boolean }>()
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
|
|
|
|
|
|
|
|
|
const { unmount } = await renderHistory()
|
|
|
|
|
expect(await screen.findByText('卸载中的单条 AI')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '智能助手整理' }))
|
|
|
|
|
unmount()
|
|
|
|
|
pending.resolve({ data: { progress_key: 'late-single' }, success: true })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(mocks.progressStart).not.toHaveBeenCalled()
|
|
|
|
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not start a batch AI redo progress boundary when its POST resolves after unmount', async () => {
|
|
|
|
|
const item = createHistory(1, '卸载中的批量 AI')
|
|
|
|
|
const pending = createDeferred<{
|
|
|
|
|
data: { history_ids: number[]; progress_key: string }
|
|
|
|
|
success: boolean
|
|
|
|
|
}>()
|
|
|
|
|
mocks.apiGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === 'system/setting/public/Storages') return Promise.resolve(storageResponse())
|
|
|
|
|
return Promise.resolve(historyResponse([item]))
|
|
|
|
|
})
|
|
|
|
|
mocks.apiPost.mockReturnValueOnce(pending.promise)
|
|
|
|
|
|
|
|
|
|
const { unmount } = await renderHistory()
|
|
|
|
|
expect(await screen.findByText('卸载中的批量 AI')).toBeInTheDocument()
|
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: '选择当前页' }))
|
|
|
|
|
await nextTick()
|
|
|
|
|
runDynamicAction('transferHistory.actions.batchAiRedo')
|
|
|
|
|
unmount()
|
|
|
|
|
pending.resolve({ data: { history_ids: [1], progress_key: 'late-batch' }, success: true })
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
expect(mocks.progressStart).not.toHaveBeenCalled()
|
|
|
|
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
})
|