-
-
Notifications
You must be signed in to change notification settings - Fork 111
Expand file tree
/
Copy pathFilesListTableHeader.spec.ts
More file actions
299 lines (245 loc) · 11.1 KB
/
FilesListTableHeader.spec.ts
File metadata and controls
299 lines (245 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
* Regression tests for Vue 2 → Vue 3 migration:
* FilesListTableHeader uses NcCheckboxRadioSwitch for "select all".
* The selectAllBind object must use `model-value` (not `checked`)
* and the event listener must use `@update:modelValue` (not `@update:checked`).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import FilesListTableHeader from './FilesListTableHeader.vue'
import { useFilesStore } from '../../store/files.js'
import { useFilesSortingStore } from '../../store/filesSorting.js'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('@nextcloud/event-bus', () => ({
emit: vi.fn(),
subscribe: vi.fn(),
}))
vi.mock('@nextcloud/logger', () => ({
getLogger: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
})),
getLoggerBuilder: vi.fn(() => ({
setApp: vi.fn().mockReturnThis(),
detectUser: vi.fn().mockReturnThis(),
build: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
})),
})),
}))
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
}))
vi.mock('@nextcloud/axios', () => ({
default: { get: vi.fn(), post: vi.fn(), delete: vi.fn(), patch: vi.fn() },
}))
vi.mock('@nextcloud/router', () => ({
generateOcsUrl: vi.fn((path) => `/ocs/v2.php${path}`),
}))
vi.mock('@nextcloud/initial-state', () => ({
loadState: vi.fn((app, key, defaultValue) => defaultValue),
}))
vi.mock('@nextcloud/moment', () => ({
default: vi.fn(() => ({ fromNow: () => '2 days ago', format: () => '2024-01-01' })),
}))
// ---------------------------------------------------------------------------
// NcCheckboxRadioSwitch stub — Vue 3 modelValue API
//
// The stub checks that the parent passes `modelValue` (not the old `checked`).
// If FilesListTableHeader.vue reverts to `checked:` in selectAllBind the prop
// will come through as an unknown attr, modelValue will be undefined, and
// the assertions below will fail.
// ---------------------------------------------------------------------------
const NcCheckboxRadioSwitchStub = {
name: 'NcCheckboxRadioSwitch',
props: {
modelValue: {
type: Boolean,
default: false,
},
indeterminate: {
type: Boolean,
default: false,
},
ariaLabel: String,
title: String,
},
emits: ['update:modelValue'],
template: '<input type="checkbox" :checked="modelValue" :data-indeterminate="indeterminate" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const createWrapper = (filesCount = 3) => {
// Pre-populate the store so `v-if="filesStore.ordered.length > 0"` passes
const filesStore = useFilesStore()
filesStore.ordered = Array.from({ length: filesCount }, (_, i) => i + 1)
return mount(FilesListTableHeader, {
props: {
nodes: Array.from({ length: filesCount }, (_, i) => ({ id: i + 1, basename: `file${i + 1}.pdf` })),
},
global: {
stubs: {
NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
FilesListTableHeaderButton: { template: '<th />' },
},
},
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FilesListTableHeader', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('RULE: selectAllBind uses model-value key (Vue 3 API)', () => {
it('passes modelValue to NcCheckboxRadioSwitch (not the old checked prop)', () => {
const wrapper = createWrapper()
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
// The stub must receive `modelValue`. If selectAllBind used `checked:`
// instead of `model-value:`, the stub prop would be undefined.
expect(stub.props('modelValue')).toBeDefined()
expect(typeof stub.props('modelValue')).toBe('boolean')
})
it('modelValue is false when nothing is selected', () => {
const wrapper = createWrapper()
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
expect(stub.props('modelValue')).toBe(false)
})
it('indeterminate is false when nothing is selected', () => {
const wrapper = createWrapper()
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
expect(stub.props('indeterminate')).toBe(false)
})
})
describe('RULE: onToggleAll called via update:modelValue (Vue 3 API)', () => {
it('selects all files when update:modelValue = true is emitted', async () => {
const wrapper = createWrapper(3)
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
// Populate the ordered list in the store to match nodes
vm.filesStore.ordered.push(1, 2, 3)
await stub.vm.$emit('update:modelValue', true)
expect(vm.selectionStore.selected).toEqual([1, 2, 3])
})
it('clears selection when update:modelValue = false is emitted', async () => {
const wrapper = createWrapper(3)
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
vm.filesStore.ordered.push(1, 2, 3)
// Select all first
await stub.vm.$emit('update:modelValue', true)
expect(vm.selectionStore.selected.length).toBe(3)
// Then deselect all
await stub.vm.$emit('update:modelValue', false)
expect(vm.selectionStore.selected).toEqual([])
})
it('does NOT trigger onToggleAll via the old @update:checked event name (Vue 2 regression guard)', async () => {
// Before the fix, the component used @update:checked. Vue 3 NcCheckboxRadioSwitch
// emits update:modelValue, not update:checked, so onToggleAll was never called.
// This test proves update:modelValue correctly reaches onToggleAll by
// verifying the observable side-effect: all files appear in selectionStore.
const wrapper = createWrapper(2)
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
await stub.vm.$emit('update:modelValue', true)
expect(vm.selectionStore.selected.length).toBe(vm.filesStore.ordered.length)
})
})
describe('RULE: isAllSelected controls modelValue correctly', () => {
it('modelValue becomes true when all files are selected', async () => {
const wrapper = createWrapper(2)
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[], set: (s: number[]) => void }, filesStore: { ordered: number[] } }
// createWrapper(2) already set ordered = [1, 2]; just set the selection
vm.selectionStore.set([1, 2])
await wrapper.vm.$nextTick()
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
expect(stub.props('modelValue')).toBe(true)
})
it('indeterminate becomes true when some (but not all) files are selected', async () => {
const wrapper = createWrapper(2)
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[], set: (s: number[]) => void }, filesStore: { ordered: number[] } }
// createWrapper(2) already set ordered = [1, 2]; just set partial selection
vm.selectionStore.set([1])
await wrapper.vm.$nextTick()
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
expect(stub.props('indeterminate')).toBe(true)
expect(stub.props('modelValue')).toBe(false)
})
})
describe('RULE: ariaSortForMode reflects filesSortingStore state', () => {
// loadState mock returns the default value, so the store initialises with
// sortingMode = 'created_at' and sortingDirection = 'desc'.
it('returns "none" when the column is sortable but not the active sort mode', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { ariaSortForMode: (mode: string, isSortable?: boolean) => string | null }
expect(vm.ariaSortForMode('status')).toBe('none')
expect(vm.ariaSortForMode('name')).toBe('none')
})
it('returns null when the column is not sortable', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { ariaSortForMode: (mode: string, isSortable?: boolean) => string | null }
expect(vm.ariaSortForMode('actions', false)).toBeNull()
})
it('returns "descending" when the column is active and direction is desc', async () => {
const wrapper = createWrapper()
const sortingStore = useFilesSortingStore()
sortingStore.sortingMode = 'created_at'
sortingStore.sortingDirection = 'desc'
await wrapper.vm.$nextTick()
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { ariaSortForMode: (mode: string) => string | null }
expect(vm.ariaSortForMode('created_at')).toBe('descending')
})
it('returns "ascending" when the column is active and direction is asc', async () => {
const wrapper = createWrapper()
const sortingStore = useFilesSortingStore()
sortingStore.sortingMode = 'status'
sortingStore.sortingDirection = 'asc'
await wrapper.vm.$nextTick()
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { ariaSortForMode: (mode: string) => string | null }
expect(vm.ariaSortForMode('status')).toBe('ascending')
})
it('sets aria-sort attribute on the active <th> element', async () => {
const wrapper = createWrapper()
const sortingStore = useFilesSortingStore()
sortingStore.sortingMode = 'created_at'
sortingStore.sortingDirection = 'desc'
await wrapper.vm.$nextTick()
const th = wrapper.find('th.files-list__row-created_at')
expect(th.attributes('aria-sort')).toBe('descending')
})
it('does not set aria-sort on non-sortable columns', async () => {
const wrapper = createWrapper()
const sortingStore = useFilesSortingStore()
sortingStore.sortingMode = 'created_at'
sortingStore.sortingDirection = 'desc'
await wrapper.vm.$nextTick()
// The actions column has no aria-sort (not sortable)
const actionsTh = wrapper.find('th.files-list__row-actions')
expect(actionsTh.attributes('aria-sort')).toBeUndefined()
})
it('sets aria-sort="none" on sortable inactive columns', async () => {
const wrapper = createWrapper()
const sortingStore = useFilesSortingStore()
sortingStore.sortingMode = 'created_at'
sortingStore.sortingDirection = 'desc'
await wrapper.vm.$nextTick()
expect(wrapper.find('th.files-list__row-status').attributes('aria-sort')).toBe('none')
expect(wrapper.find('th.files-list__row-signers').attributes('aria-sort')).toBe('none')
})
})
})