Skip to content

Commit 3b2ce85

Browse files
test(FilesListTableHeader): add Vue 3 API regression tests for select-all checkbox
Add unit tests to guard against regressions to the Vue 2 → Vue 3 NcCheckboxRadioSwitch API migration in FilesListTableHeader.vue. The NcCheckboxRadioSwitch stub enforces the Vue 3 contract (:model-value / @update:modelValue). Tests verify: - selectAllBind passes model-value (not the legacy checked key) - modelValue is false when nothing is selected - indeterminate is true when a subset of files is selected - modelValue is true when all files are selected - onToggleAll correctly selects and deselects all files via update:modelValue Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent d2f5a99 commit 3b2ce85

1 file changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/**
7+
* Regression tests for Vue 2 → Vue 3 migration:
8+
* FilesListTableHeader uses NcCheckboxRadioSwitch for "select all".
9+
* The selectAllBind object must use `model-value` (not `checked`)
10+
* and the event listener must use `@update:modelValue` (not `@update:checked`).
11+
*/
12+
13+
import { beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { mount } from '@vue/test-utils'
15+
import { setActivePinia, createPinia } from 'pinia'
16+
import FilesListTableHeader from './FilesListTableHeader.vue'
17+
import { useFilesStore } from '../../store/files.js'
18+
19+
// ---------------------------------------------------------------------------
20+
// Mocks
21+
// ---------------------------------------------------------------------------
22+
23+
vi.mock('@nextcloud/event-bus', () => ({
24+
emit: vi.fn(),
25+
subscribe: vi.fn(),
26+
}))
27+
28+
vi.mock('@nextcloud/logger', () => ({
29+
getLogger: vi.fn(() => ({
30+
error: vi.fn(),
31+
warn: vi.fn(),
32+
info: vi.fn(),
33+
debug: vi.fn(),
34+
})),
35+
getLoggerBuilder: vi.fn(() => ({
36+
setApp: vi.fn().mockReturnThis(),
37+
detectUser: vi.fn().mockReturnThis(),
38+
build: vi.fn(() => ({
39+
error: vi.fn(),
40+
warn: vi.fn(),
41+
info: vi.fn(),
42+
debug: vi.fn(),
43+
})),
44+
})),
45+
}))
46+
47+
vi.mock('@nextcloud/auth', () => ({
48+
getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })),
49+
}))
50+
51+
vi.mock('@nextcloud/axios', () => ({
52+
default: { get: vi.fn(), post: vi.fn(), delete: vi.fn(), patch: vi.fn() },
53+
}))
54+
55+
vi.mock('@nextcloud/router', () => ({
56+
generateOcsUrl: vi.fn((path) => `/ocs/v2.php${path}`),
57+
}))
58+
59+
vi.mock('@nextcloud/initial-state', () => ({
60+
loadState: vi.fn((app, key, defaultValue) => defaultValue),
61+
}))
62+
63+
vi.mock('@nextcloud/moment', () => ({
64+
default: vi.fn(() => ({ fromNow: () => '2 days ago', format: () => '2024-01-01' })),
65+
}))
66+
67+
// ---------------------------------------------------------------------------
68+
// NcCheckboxRadioSwitch stub — Vue 3 modelValue API
69+
//
70+
// The stub checks that the parent passes `modelValue` (not the old `checked`).
71+
// If FilesListTableHeader.vue reverts to `checked:` in selectAllBind the prop
72+
// will come through as an unknown attr, modelValue will be undefined, and
73+
// the assertions below will fail.
74+
// ---------------------------------------------------------------------------
75+
const NcCheckboxRadioSwitchStub = {
76+
name: 'NcCheckboxRadioSwitch',
77+
props: {
78+
modelValue: {
79+
type: Boolean,
80+
default: false,
81+
},
82+
indeterminate: {
83+
type: Boolean,
84+
default: false,
85+
},
86+
ariaLabel: String,
87+
title: String,
88+
},
89+
emits: ['update:modelValue'],
90+
template: '<input type="checkbox" :checked="modelValue" :data-indeterminate="indeterminate" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
91+
}
92+
93+
// ---------------------------------------------------------------------------
94+
// Helpers
95+
// ---------------------------------------------------------------------------
96+
97+
const createWrapper = (filesCount = 3) => {
98+
// Pre-populate the store so `v-if="filesStore.ordered.length > 0"` passes
99+
const filesStore = useFilesStore()
100+
filesStore.ordered = Array.from({ length: filesCount }, (_, i) => i + 1)
101+
102+
return mount(FilesListTableHeader, {
103+
props: {
104+
nodes: Array.from({ length: filesCount }, (_, i) => ({ id: i + 1, basename: `file${i + 1}.pdf` })),
105+
},
106+
global: {
107+
stubs: {
108+
NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
109+
FilesListTableHeaderButton: { template: '<th />' },
110+
},
111+
},
112+
})
113+
}
114+
115+
// ---------------------------------------------------------------------------
116+
// Tests
117+
// ---------------------------------------------------------------------------
118+
119+
describe('FilesListTableHeader', () => {
120+
beforeEach(() => {
121+
setActivePinia(createPinia())
122+
vi.clearAllMocks()
123+
})
124+
125+
describe('RULE: selectAllBind uses model-value key (Vue 3 API)', () => {
126+
it('passes modelValue to NcCheckboxRadioSwitch (not the old checked prop)', () => {
127+
const wrapper = createWrapper()
128+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
129+
130+
// The stub must receive `modelValue`. If selectAllBind used `checked:`
131+
// instead of `model-value:`, the stub prop would be undefined.
132+
expect(stub.props('modelValue')).toBeDefined()
133+
expect(typeof stub.props('modelValue')).toBe('boolean')
134+
})
135+
136+
it('modelValue is false when nothing is selected', () => {
137+
const wrapper = createWrapper()
138+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
139+
140+
expect(stub.props('modelValue')).toBe(false)
141+
})
142+
143+
it('indeterminate is false when nothing is selected', () => {
144+
const wrapper = createWrapper()
145+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
146+
147+
expect(stub.props('indeterminate')).toBe(false)
148+
})
149+
})
150+
151+
describe('RULE: onToggleAll called via update:modelValue (Vue 3 API)', () => {
152+
it('selects all files when update:modelValue = true is emitted', async () => {
153+
const wrapper = createWrapper(3)
154+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
155+
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
156+
157+
// Populate the ordered list in the store to match nodes
158+
vm.filesStore.ordered.push(1, 2, 3)
159+
160+
await stub.vm.$emit('update:modelValue', true)
161+
162+
expect(vm.selectionStore.selected).toEqual([1, 2, 3])
163+
})
164+
165+
it('clears selection when update:modelValue = false is emitted', async () => {
166+
const wrapper = createWrapper(3)
167+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
168+
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
169+
170+
vm.filesStore.ordered.push(1, 2, 3)
171+
172+
// Select all first
173+
await stub.vm.$emit('update:modelValue', true)
174+
expect(vm.selectionStore.selected.length).toBe(3)
175+
176+
// Then deselect all
177+
await stub.vm.$emit('update:modelValue', false)
178+
expect(vm.selectionStore.selected).toEqual([])
179+
})
180+
181+
it('does NOT trigger onToggleAll via the old @update:checked event name (Vue 2 regression guard)', async () => {
182+
// Before the fix, the component used @update:checked. Vue 3 NcCheckboxRadioSwitch
183+
// emits update:modelValue, not update:checked, so onToggleAll was never called.
184+
// This test proves update:modelValue correctly reaches onToggleAll by
185+
// verifying the observable side-effect: all files appear in selectionStore.
186+
const wrapper = createWrapper(2)
187+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
188+
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[] }, filesStore: { ordered: number[] } }
189+
190+
await stub.vm.$emit('update:modelValue', true)
191+
192+
expect(vm.selectionStore.selected.length).toBe(vm.filesStore.ordered.length)
193+
})
194+
})
195+
196+
describe('RULE: isAllSelected controls modelValue correctly', () => {
197+
it('modelValue becomes true when all files are selected', async () => {
198+
const wrapper = createWrapper(2)
199+
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[], set: (s: number[]) => void }, filesStore: { ordered: number[] } }
200+
201+
// createWrapper(2) already set ordered = [1, 2]; just set the selection
202+
vm.selectionStore.set([1, 2])
203+
await wrapper.vm.$nextTick()
204+
205+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
206+
expect(stub.props('modelValue')).toBe(true)
207+
})
208+
209+
it('indeterminate becomes true when some (but not all) files are selected', async () => {
210+
const wrapper = createWrapper(2)
211+
const vm = wrapper.vm as InstanceType<typeof FilesListTableHeader> & { selectionStore: { selected: number[], set: (s: number[]) => void }, filesStore: { ordered: number[] } }
212+
213+
// createWrapper(2) already set ordered = [1, 2]; just set partial selection
214+
vm.selectionStore.set([1])
215+
await wrapper.vm.$nextTick()
216+
217+
const stub = wrapper.findComponent(NcCheckboxRadioSwitchStub)
218+
expect(stub.props('indeterminate')).toBe(true)
219+
expect(stub.props('modelValue')).toBe(false)
220+
})
221+
})
222+
})

0 commit comments

Comments
 (0)