From 265027404020dc958a1e923a463b163f27d82ebe Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Thu, 9 Jul 2026 10:17:22 +0000 Subject: [PATCH 01/13] Add skeleton VTL tests for RearrangeChannelsPage --- .../__tests__/RearrangeChannelsPage.spec.js | 128 ++++++++---------- 1 file changed, 53 insertions(+), 75 deletions(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index 006c16a7637..056ff3fe5fc 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -1,98 +1,76 @@ -import { shallowMount } from '@vue/test-utils'; -import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line -import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line -import makeStore from '../../__tests__/utils/makeStore'; +import { render, screen, waitFor } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import-x/named +import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line import-x/named +import { createTranslator } from 'kolibri/utils/i18n'; import RearrangeChannelsPage from '../RearrangeChannelsPage'; +import makeStore from '../../__tests__/utils/makeStore'; +import { PageNames } from '../../constants'; + +const { instructions$, noChannels$ } = createTranslator( + RearrangeChannelsPage.name, + RearrangeChannelsPage.$trs, +); jest.mock('../../composables/useContentTasks'); jest.mock('kolibri/composables/useUser'); jest.mock('kolibri/composables/useSnackbar'); -RearrangeChannelsPage.methods.postNewOrder = () => Promise.resolve(); -RearrangeChannelsPage.methods.fetchChannels = () => { - return Promise.resolve([ - { id: '1', name: 'Channel 1' }, - { id: '2', name: 'Channel 2' }, - ]); -}; -async function makeWrapper() { - const store = makeStore(); - useUser.mockImplementation(() => useUserMock({ canManageContent: true })); - const wrapper = shallowMount(RearrangeChannelsPage, { - store, +function createRouter() { + return new VueRouter({ + routes: [ + { name: PageNames.REARRANGE_CHANNELS, path: '/content/reorder_channels' }, + { name: PageNames.MANAGE_CONTENT_PAGE, path: '/content' }, + ], }); - // Have to wait to let the channels data load - await global.flushPromises(); - return { wrapper }; } +const MOCK_CHANNELS = [ + { id: '1', name: 'Channel 1' }, + { id: '2', name: 'Channel 2' }, +]; + +RearrangeChannelsPage.methods.postNewOrder = () => Promise.resolve(); +RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve(MOCK_CHANNELS); + describe('RearrangeChannelsPage', () => { - const createSnackbar = jest.fn(); - beforeAll(() => { - useSnackbar.mockImplementation(() => useSnackbarMock({ createSnackbar })); - }); + let createSnackbar; - async function simulateSort(wrapper) { - const dragContainer = wrapper.findComponent({ name: 'DragContainer' }); - dragContainer.vm.$emit('sort', { - newArray: [wrapper.vm.channels[1], wrapper.vm.channels[0]], - }); - expect(wrapper.vm.postNewOrder).toHaveBeenCalledWith(['2', '1']); - await global.flushPromises(); - } + const renderComponent = async () => { + createSnackbar = jest.fn(); + useUser.mockImplementation(() => useUserMock({ canManageContent: true })); + useSnackbar.mockImplementation(() => useSnackbarMock({ createSnackbar })); - it('loads the data on mount', async () => { - const { wrapper } = await makeWrapper(); - expect(wrapper.vm.loading).toBe(false); - expect(wrapper.vm.channels).toHaveLength(2); - }); + const store = makeStore(); + const router = createRouter(); + await router.push({ name: PageNames.REARRANGE_CHANNELS }); + return render(RearrangeChannelsPage, { store, router }); + }; - it('handles a successful @sort event properly', async () => { - const { wrapper } = await makeWrapper(); - wrapper.vm.postNewOrder = jest.fn().mockResolvedValue(); - wrapper.vm.$store.dispatch = jest.fn(); - await simulateSort(wrapper); - expect(createSnackbar).toHaveBeenCalledWith('Channel order saved'); - expect(wrapper.vm.channels[0].id).toEqual('2'); - expect(wrapper.vm.channels[1].id).toEqual('1'); + beforeEach(() => { + jest.clearAllMocks(); }); - it('handles a failed @sort event properly', async () => { - const { wrapper } = await makeWrapper(); - wrapper.vm.postNewOrder = jest.fn().mockRejectedValue(); - wrapper.vm.$store.dispatch = jest.fn(); - await simulateSort(wrapper); - expect(createSnackbar).toHaveBeenCalledWith('There was a problem reordering the channels'); - // Channels array is reset after an error - expect(wrapper.vm.channels[0].id).toEqual('1'); - expect(wrapper.vm.channels[1].id).toEqual('2'); + it('loads the data on mount', async () => { + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(MOCK_CHANNELS[0].name)).toBeInTheDocument(); + expect(screen.getByText(MOCK_CHANNELS[1].name)).toBeInTheDocument(); + }); }); - // Will mock the handleOrderChange method to test these cases synchronousy, - // since that method should be tested by the previous tests. - it('handles a @moveUp event properly', async () => { - const { wrapper } = await makeWrapper(); - const spy = (wrapper.vm.handleOrderChange = jest.fn()); - const dragSortWidget = wrapper.findAllComponents({ name: 'DragSortWidget' }).at(1); - dragSortWidget.vm.$emit('moveUp'); - expect(spy).toHaveBeenCalledWith({ - newArray: [ - { id: '2', name: 'Channel 2' }, - { id: '1', name: 'Channel 1' }, - ], + it('shows the instructions text', async () => { + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(instructions$())).toBeInTheDocument(); }); }); - it('handles a @moveDown event properly', async () => { - const { wrapper } = await makeWrapper(); - const spy = (wrapper.vm.handleOrderChange = jest.fn()); - const dragSortWidget = wrapper.findAllComponents({ name: 'DragSortWidget' }).at(0); - dragSortWidget.vm.$emit('moveDown'); - expect(spy).toHaveBeenCalledWith({ - newArray: [ - { id: '2', name: 'Channel 2' }, - { id: '1', name: 'Channel 1' }, - ], + it('shows a message when there are no channels', async () => { + RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve([]); + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(noChannels$())).toBeInTheDocument(); }); }); }); From 240109a1ac632a606058684f9696c43b1d0cbaab Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Mon, 13 Jul 2026 03:01:42 +0000 Subject: [PATCH 02/13] Add VTL tests for RearrangeChannelsPage skeleton and simple cases --- .../__tests__/RearrangeChannelsPage.spec.js | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index 056ff3fe5fc..6cadbd763e6 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/vue'; +import { render, screen, waitFor, fireEvent } from '@testing-library/vue'; import VueRouter from 'vue-router'; import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import-x/named import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line import-x/named @@ -7,12 +7,28 @@ import RearrangeChannelsPage from '../RearrangeChannelsPage'; import makeStore from '../../__tests__/utils/makeStore'; import { PageNames } from '../../constants'; -const { instructions$, noChannels$ } = createTranslator( +const { instructions$, noChannels$, successNotification$ } = createTranslator( RearrangeChannelsPage.name, RearrangeChannelsPage.$trs, ); jest.mock('../../composables/useContentTasks'); +jest.mock('kolibri-common/composables/usePageLoading'); +jest.mock('kolibri-common/components/sortable/DragContainer', () => ({ + default: { + name: 'DragContainer', + props: ['items'], + template: ` +
+ + +
+ `, + }, + } +)); jest.mock('kolibri/composables/useUser'); jest.mock('kolibri/composables/useSnackbar'); @@ -30,9 +46,6 @@ const MOCK_CHANNELS = [ { id: '2', name: 'Channel 2' }, ]; -RearrangeChannelsPage.methods.postNewOrder = () => Promise.resolve(); -RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve(MOCK_CHANNELS); - describe('RearrangeChannelsPage', () => { let createSnackbar; @@ -49,6 +62,8 @@ describe('RearrangeChannelsPage', () => { beforeEach(() => { jest.clearAllMocks(); + RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve(MOCK_CHANNELS); + RearrangeChannelsPage.methods.postNewOrder = () => Promise.resolve(); }); it('loads the data on mount', async () => { @@ -67,10 +82,25 @@ describe('RearrangeChannelsPage', () => { }); it('shows a message when there are no channels', async () => { - RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve([]); - await renderComponent(); - await waitFor(() => { - expect(screen.getByText(noChannels$())).toBeInTheDocument(); + RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve([]); + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(noChannels$())).toBeInTheDocument(); + }); + }); + + it('handles a successful @sort event properly', async () => { + await renderComponent(); + await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + + await fireEvent.click(screen.getByTestId('trigger-sort')); + + await waitFor(() => { + expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); + }); + const titles = screen + .getAllByText(new RegExp(`^(${MOCK_CHANNELS[0].name}|${MOCK_CHANNELS[1].name})$`)) + .map(el => el.textContent); + expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); }); }); -}); From a947598a61e507a2d7ad80658996733a021b55c5 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 21 Jul 2026 01:34:52 +0000 Subject: [PATCH 03/13] test: add sort event test for RearrangeChannelsPage using sortablejs mock --- .../__tests__/RearrangeChannelsPage.spec.js | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index 6cadbd763e6..875cd93130a 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/vue'; +import { render, screen, waitFor } from '@testing-library/vue'; import VueRouter from 'vue-router'; import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import-x/named import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line import-x/named @@ -14,21 +14,15 @@ const { instructions$, noChannels$, successNotification$ } = createTranslator( jest.mock('../../composables/useContentTasks'); jest.mock('kolibri-common/composables/usePageLoading'); -jest.mock('kolibri-common/components/sortable/DragContainer', () => ({ - default: { - name: 'DragContainer', - props: ['items'], - template: ` -
- - -
- `, - }, - } -)); +jest.mock( + 'sortablejs', + () => + jest.fn().mockImplementation((el, options) => ({ + destroy: jest.fn(), + options, + })), + { virtual: true }, +); jest.mock('kolibri/composables/useUser'); jest.mock('kolibri/composables/useSnackbar'); @@ -82,25 +76,29 @@ describe('RearrangeChannelsPage', () => { }); it('shows a message when there are no channels', async () => { - RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve([]); - await renderComponent(); - await waitFor(() => { - expect(screen.getByText(noChannels$())).toBeInTheDocument(); - }); + RearrangeChannelsPage.methods.fetchChannels = () => Promise.resolve([]); + await renderComponent(); + await waitFor(() => { + expect(screen.getByText(noChannels$())).toBeInTheDocument(); }); + }); - it('handles a successful @sort event properly', async () => { - await renderComponent(); - await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + it('handles a successful @sort event properly', async () => { + const Sortable = require('sortablejs'); + await renderComponent(); + await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); - await fireEvent.click(screen.getByTestId('trigger-sort')); + // Simulate the drag ending by calling the onEnd callback SortableJS + // would normally call itself once the pointer is released. + const { onEnd } = Sortable.mock.results[0].value.options; + onEnd({ oldIndex: 0, newIndex: 1, item: document.createElement('div') }); - await waitFor(() => { - expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); - }); - const titles = screen - .getAllByText(new RegExp(`^(${MOCK_CHANNELS[0].name}|${MOCK_CHANNELS[1].name})$`)) - .map(el => el.textContent); - expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); + await waitFor(() => { + expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); }); + const channelNames = MOCK_CHANNELS.map(channel => channel.name); + const matchesChannelName = text => channelNames.includes(text.trim()); + const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); + expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); }); +}); From cae00565e80dfe68d6756bf32039504be429c004 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 21 Jul 2026 01:51:02 +0000 Subject: [PATCH 04/13] test: add moveUp event test for RearrangeChannelsPage --- .../__tests__/RearrangeChannelsPage.spec.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index 875cd93130a..e948dd68717 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -1,12 +1,15 @@ -import { render, screen, waitFor } from '@testing-library/vue'; +import { render, screen, waitFor, fireEvent } from '@testing-library/vue'; import VueRouter from 'vue-router'; import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line import-x/named import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line import-x/named import { createTranslator } from 'kolibri/utils/i18n'; +import { dragSortStrings } from 'kolibri-common/components/sortable/dragSortStrings'; import RearrangeChannelsPage from '../RearrangeChannelsPage'; import makeStore from '../../__tests__/utils/makeStore'; import { PageNames } from '../../constants'; +const { moveItemUpLabel$ } = dragSortStrings; + const { instructions$, noChannels$, successNotification$ } = createTranslator( RearrangeChannelsPage.name, RearrangeChannelsPage.$trs, @@ -101,4 +104,22 @@ describe('RearrangeChannelsPage', () => { const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); }); + + it('handles a moveUp event properly', async () => { + await renderComponent(); + await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + + const upButtons = screen.getAllByRole('button', { + name: moveItemUpLabel$({ item: MOCK_CHANNELS[1].name }), + }); + await fireEvent.click(upButtons[0]); + + await waitFor(() => { + expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); + }); + const channelNames = MOCK_CHANNELS.map(channel => channel.name); + const matchesChannelName = text => channelNames.includes(text.trim()); + const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); + expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); + }); }); From c836d07fe314a660abb1483174bde2f2faba72dd Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 21 Jul 2026 01:52:47 +0000 Subject: [PATCH 05/13] test: add moveDown event test for RearrangeChannelsPage --- .../__tests__/RearrangeChannelsPage.spec.js | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index e948dd68717..e0728d155ab 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -8,7 +8,7 @@ import RearrangeChannelsPage from '../RearrangeChannelsPage'; import makeStore from '../../__tests__/utils/makeStore'; import { PageNames } from '../../constants'; -const { moveItemUpLabel$ } = dragSortStrings; +const { moveItemUpLabel$, moveItemDownLabel$ } = dragSortStrings; const { instructions$, noChannels$, successNotification$ } = createTranslator( RearrangeChannelsPage.name, @@ -122,4 +122,22 @@ describe('RearrangeChannelsPage', () => { const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); }); + + it('handles a moveDown event properly', async () => { + await renderComponent(); + await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + + const downButtons = screen.getAllByRole('button', { + name: moveItemDownLabel$({ item: MOCK_CHANNELS[0].name }), + }); + await fireEvent.click(downButtons[0]); + + await waitFor(() => { + expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); + }); + const channelNames = MOCK_CHANNELS.map(channel => channel.name); + const matchesChannelName = text => channelNames.includes(text.trim()); + const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); + expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); + }); }); From f28acb05a18bd18fa44fe8cc71132c28838469a7 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 21 Jul 2026 02:04:59 +0000 Subject: [PATCH 06/13] test: add failed sort event test for RearrangeChannelsPage --- .../__tests__/RearrangeChannelsPage.spec.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index e0728d155ab..0132ecbbcd1 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -10,7 +10,7 @@ import { PageNames } from '../../constants'; const { moveItemUpLabel$, moveItemDownLabel$ } = dragSortStrings; -const { instructions$, noChannels$, successNotification$ } = createTranslator( +const { instructions$, noChannels$, successNotification$, failureNotification$ } = createTranslator( RearrangeChannelsPage.name, RearrangeChannelsPage.$trs, ); @@ -123,6 +123,26 @@ describe('RearrangeChannelsPage', () => { expect(titles).toEqual([MOCK_CHANNELS[1].name, MOCK_CHANNELS[0].name]); }); + it('handles a failed @sort event properly', async () => { + const Sortable = require('sortablejs'); + RearrangeChannelsPage.methods.postNewOrder = () => Promise.reject(); + await renderComponent(); + await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + + const { onEnd } = Sortable.mock.results[0].value.options; + onEnd({ oldIndex: 0, newIndex: 1, item: document.createElement('div') }); + + await waitFor(() => { + expect(createSnackbar).toHaveBeenCalledWith(failureNotification$()); + }); + await waitFor(() => { + const channelNames = MOCK_CHANNELS.map(channel => channel.name); + const matchesChannelName = text => channelNames.includes(text.trim()); + const titles = screen.getAllByText(matchesChannelName).map(el => el.textContent.trim()); + expect(titles).toEqual([MOCK_CHANNELS[0].name, MOCK_CHANNELS[1].name]); + }); + }); + it('handles a moveDown event properly', async () => { await renderComponent(); await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); From a3bf0ba5ca73f27757362203b663a0baaf747f41 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Thu, 23 Jul 2026 05:13:22 +0000 Subject: [PATCH 07/13] test: add skeleton VTL tests for AvailableChannelsPage --- .../__tests__/AvailableChannelsPage.spec.js | 344 +++--------------- 1 file changed, 56 insertions(+), 288 deletions(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js index 7a6ec71b1bd..3f1d65e3287 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js @@ -1,317 +1,85 @@ -import { mount } from '@vue/test-utils'; +import { render, screen, waitFor } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { createTranslator } from 'kolibri/utils/i18n'; import AvailableChannelsPage from '../AvailableChannelsPage'; +import FilteredChannelListContainer from '../ManageContentPage/FilteredChannelListContainer'; import { makeAvailableChannelsPageStore } from '../../__tests__/utils/makeStore'; -import router from './testRouter'; +import { PageNames } from '../../constants'; jest.mock('kolibri/urls'); jest.mock('kolibri/client'); jest.mock('kolibri-common/composables/usePageLoading'); -function makeWrapper(options = {}) { - const { store, props = {} } = options; - const defaultProps = {}; - const node = document.createElement('div'); - document.body.appendChild(node); - return mount(AvailableChannelsPage, { - propsData: { ...defaultProps, ...props }, - store: store || makeAvailableChannelsPageStore(), - ...router, - attachTo: node, +const { channelTokenButtonLabel$, importResourcesHeader$ } = createTranslator( + AvailableChannelsPage.name, + AvailableChannelsPage.$trs, +); +const { numChannelsAvailable$ } = createTranslator( + FilteredChannelListContainer.name, + FilteredChannelListContainer.$trs, +); + +function createRouter() { + return new VueRouter({ + routes: [ + { name: 'AVAILABLE_CHANNELS', path: '/content/channels' }, + { name: PageNames.MANAGE_CONTENT_PAGE, path: '/content' }, + { name: 'SELECT_CONTENT', path: '/content/channel/:channel_id?' }, + { + name: PageNames.NEW_CHANNEL_VERSION_PAGE, + path: '/content/manage_channel/:channel_id/upgrade', + }, + ], }); } -// prettier-ignore -function getElements(wrapper) { - return { - noChannels: () => wrapper.find('.no-channels'), - channelsList: () => wrapper.find('.channels-list'), - channelsAvailableText: () => wrapper.find('[data-testid="available"]').text().trim(), - channelListItems: () => wrapper.findAllComponents({ name: 'WithImportDetails' }), - ChannelTokenModal: () => wrapper.findComponent({ name: 'ChannelTokenModal' }), - filters: () => wrapper.find('.filters'), - languageFilter: () => wrapper.findComponent({ name: 'KSelect' }), - titleText: () => wrapper.find('[data-testid="title"]').text().trim(), - titleFilter: () => wrapper.findComponent({ name: 'FilterTextbox' }), - unlistedChannelsButton: () => wrapper.find('[data-testid="token-button"]'), - filterComponent: () => wrapper.findComponent({name: 'FilteredChannelListContainer'}), - } -} - -function testChannelVisibility(wrapper, visibilities) { - const channels = getElements(wrapper).channelListItems(); - visibilities.forEach((v, i) => { - if (v) { - expect(channels.at(i).element).toBeVisible(); - } else { - expect(channels.at(i).element).not.toBeVisible(); - } +async function renderComponent({ store } = {}) { + const router = createRouter(); + await router.push({ name: 'AVAILABLE_CHANNELS' }); + return render(AvailableChannelsPage, { + store: store || makeAvailableChannelsPageStore(), + router, }); } -describe('availableChannelsPage', () => { - let store; - - beforeEach(() => { - store = makeAvailableChannelsPageStore(); - }); - - function setTransferType(transferType) { - store.commit('manageContent/wizard/SET_TRANSFER_TYPE', transferType); - } - +describe('AvailableChannelsPage', () => { it('in REMOTEIMPORT mode, the unlisted channel button is available', async () => { - // ...and clicking it opens the channel token modal - setTransferType('remoteimport'); - const wrapper = makeWrapper({ store }); - const { unlistedChannelsButton, ChannelTokenModal } = getElements(wrapper); - // prettier-ignore - const button = unlistedChannelsButton(); - button.trigger('click'); - await wrapper.vm.$nextTick(); - expect(ChannelTokenModal().exists()).toEqual(true); - }); - - it('in LOCALIMPORT mode, the unlisted channel button is not available', () => { - setTransferType('localexport'); - const wrapper = makeWrapper({ store }); - const { unlistedChannelsButton } = getElements(wrapper); - expect(unlistedChannelsButton().exists()).toBe(false); - }); - - it('in LOCALIMPORT mode, the back link text and title are correct', () => { - setTransferType('localimport'); - const selectedDrive = store.state.manageContent.wizard.driveList.find( - ({ id }) => id === 'f9e29616935fbff37913ed46bf20e2c0', - ); - store.state.manageContent.wizard.selectedDrive = selectedDrive; - const wrapper = makeWrapper({ store }); - const { titleText } = getElements(wrapper); - expect(titleText()).toEqual('Select resources for import'); - }); - - it('in REMOTEIMPORT mode, the back link text and title are correct', () => { - setTransferType('remoteimport'); - const wrapper = makeWrapper({ store }); - const { titleText } = getElements(wrapper); - expect(titleText()).toEqual('Select resources for import'); - }); - - it('in REMOTEIMPORT/LOCALIMPORT shows the correct number of channels available message', () => { - setTransferType('localimport'); - const wrapper = makeWrapper({ store }); - const { channelsAvailableText, noChannels } = getElements(wrapper); - expect(channelsAvailableText()).toEqual('4 channels available'); - expect(noChannels().exists()).toEqual(false); - }); - - it('if there are no channels, then filters do not appear', () => { - store.commit('manageContent/wizard/SET_AVAILABLE_CHANNELS', []); - const wrapper = makeWrapper({ store }); - const { filters } = getElements(wrapper); - expect(filters().exists()).toEqual(false); - }); - - it('in LOCALIMPORT/REMOTEIMPORT, channel item (not) on device has the correct props', () => { - const wrapper = makeWrapper(); - const { channelListItems } = getElements(wrapper); - const channels = channelListItems(); - const channelNProps = n => channels.at(n).props(); - expect(channelNProps(0).onDevice).toEqual(true); - expect(channelNProps(1).onDevice).toEqual(true); - expect(channelNProps(2).onDevice).toEqual(false); - expect(channelNProps(3).onDevice).toEqual(false); - }); - - it('IN LOCALIMPORT/REMOTEIMPORT, with no filters, all appear', () => { - setTransferType('localimport'); - const wrapper = makeWrapper({ store }); - const { filterComponent } = getElements(wrapper); - const { titleFilter, languageFilter } = filterComponent().vm; - expect(titleFilter).toEqual(''); - expect(languageFilter.value).toEqual('ALL'); - testChannelVisibility(wrapper, [true, true, true, true]); - }); - - it('the correct language filter options appear', () => { - const wrapper = makeWrapper(); - const { languageFilter } = getElements(wrapper); - // Fake labels for now - const expected = [ - { label: 'All languages', value: 'ALL' }, - { label: 'English', value: 'en' }, - { label: 'German', value: 'de' }, - ]; - expect(languageFilter().props().options).toEqual(expected); - }); - - it('with language filter, the correct channels appear', async () => { - const wrapper = makeWrapper(); - const { languageFilter } = getElements(wrapper); - const filter = languageFilter(); - await wrapper.vm.$nextTick(); - filter.vm.selection = { label: 'English', value: 'en' }; - - await wrapper.vm.$nextTick(); - testChannelVisibility(wrapper, [true, false, false, false]); - }); - - it('with keyword filter, the correct channels appear', async () => { - const wrapper = makeWrapper(); - const { titleFilter, filterComponent } = getElements(wrapper); - const filter = titleFilter(); - // Can't trigger 'input' event; need to set new value manually - filter.vm.model = 'bir ch'; - await wrapper.vm.$nextTick(); - expect(filterComponent().vm.titleFilter).toEqual('bir ch'); - testChannelVisibility(wrapper, [false, false, true, false]); - }); + const store = makeAvailableChannelsPageStore(); + store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); + await renderComponent({ store }); - it('with both filters, the correct channels appear', async () => { - const wrapper = makeWrapper(); - const { languageFilter, titleFilter } = getElements(wrapper); - const lFilter = languageFilter(); - const tFilter = titleFilter(); - tFilter.vm.model = 'hund'; - await wrapper.vm.$nextTick(); - lFilter.vm.selection = { label: 'German', value: 'de' }; - await wrapper.vm.$nextTick(); - testChannelVisibility(wrapper, [false, false, false, true]); - }); - - it('the "select" link goes to the correct place', () => { - const wrapper = makeWrapper(); - const { channelListItems } = getElements(wrapper); - const channels = channelListItems(); - // prettier-ignore - const link = channels.at(0).findComponent({ name: 'KRouterLink' }); - expect(link.props().to).toMatchObject({ - name: 'SELECT_CONTENT', - params: { - channel_id: 'awesome_channel', - }, - query: { - drive_id: undefined, - }, + await waitFor(() => { + expect(screen.getByText(channelTokenButtonLabel$())).toBeInTheDocument(); }); }); - describe('handleSubmitToken', () => { - let wrapper; - let pushSpy; - - beforeEach(() => { - store = makeAvailableChannelsPageStore(); - store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); - wrapper = makeWrapper({ store }); - pushSpy = jest.spyOn(wrapper.vm.$router, 'push').mockResolvedValue(); - }); - - afterEach(() => { - pushSpy.mockRestore(); - wrapper.destroy(); - }); - - it('single-channel token: routes to selectContentPage with token in query', () => { - wrapper.vm.handleSubmitToken({ token: 'test-token-xyz', channels: [{ id: 'channel-abc' }] }); - expect(pushSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'SELECT_CONTENT', - query: expect.objectContaining({ token: 'test-token-xyz' }), - }), - ); - }); + it('in LOCALIMPORT mode, the unlisted channel button is not available', async () => { + const store = makeAvailableChannelsPageStore(); + store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'localimport'); + await renderComponent({ store }); - it('collection token (multiple channels): does not route to SELECT_CONTENT', () => { - wrapper.vm.handleSubmitToken({ - token: 'collection-token', - channels: [{ id: 'ch-1' }, { id: 'ch-2' }], - }); - expect(pushSpy).toHaveBeenCalledTimes(1); - const pushed = pushSpy.mock.calls[0][0]; - expect(pushed.name).not.toBe('SELECT_CONTENT'); - expect(pushed.query).toMatchObject({ token: 'collection-token' }); + await waitFor(() => { + expect(screen.queryByText(channelTokenButtonLabel$())).not.toBeInTheDocument(); }); }); - describe('handleSubmitToken redirect', () => { - beforeEach(async () => { - // Reset shared router to AVAILABLE_CHANNELS so each navigation test starts fresh - await router.router.push({ name: 'AVAILABLE_CHANNELS' }).catch(() => {}); - }); - - it('redirects to NewChannelVersionPage when token-resolved version differs from installed', async () => { - store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); - const wrapper = makeWrapper({ store }); - - // awesome_channel is installed at version 10 (from test data in makeStore) - // Token resolves to version 11 — a different version - await wrapper.vm.handleSubmitToken({ - token: 'my-token', - channels: [{ id: 'awesome_channel', version: 11 }], - }); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.$route.name).toBe('NEW_CHANNEL_VERSION_PAGE'); - expect(wrapper.vm.$route.params.channel_id).toBe('awesome_channel'); - expect(wrapper.vm.$route.query.token).toBe('my-token'); - }); - - it('redirects to NewChannelVersionPage for an installed draft (version 0) even when versions match', async () => { - // A draft always reports version 0, so an installed draft has the same version - // number as a changed draft. The version-difference check alone would skip the - // upgrade flow, so drafts must always route to it. - const draftStore = makeAvailableChannelsPageStore({ - channelList: [ - { - id: 'draft_channel', - name: 'Draft', - version: 0, - available: true, - on_device_resources: 5, - on_device_file_size: 100, - }, - ], - }); - draftStore.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); - const wrapper = makeWrapper({ store: draftStore }); - - await wrapper.vm.handleSubmitToken({ - token: 'my-token', - channels: [{ id: 'draft_channel', version: 0 }], - }); - await wrapper.vm.$nextTick(); + it('shows the correct title', async () => { + const store = makeAvailableChannelsPageStore(); + await renderComponent({ store }); - expect(wrapper.vm.$route.name).toBe('NEW_CHANNEL_VERSION_PAGE'); - expect(wrapper.vm.$route.params.channel_id).toBe('draft_channel'); - expect(wrapper.vm.$route.query.token).toBe('my-token'); + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent(importResourcesHeader$()); }); + }); - it('goes to SelectContentPage when token-resolved version matches installed', async () => { - store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); - const wrapper = makeWrapper({ store }); - - // awesome_channel is installed at version 10; token also resolves to version 10 - await wrapper.vm.handleSubmitToken({ - token: 'my-token', - channels: [{ id: 'awesome_channel', version: 10 }], - }); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.$route.name).toBe('SELECT_CONTENT'); - }); - - it('goes to SelectContentPage when channel is not installed', async () => { - store.commit('manageContent/wizard/SET_TRANSFER_TYPE', 'remoteimport'); - const wrapper = makeWrapper({ store }); - - // new_uninstalled_channel is not in channelsOnDevice - await wrapper.vm.handleSubmitToken({ - token: 'my-token', - channels: [{ id: 'new_uninstalled_channel', version: 1 }], - }); - await wrapper.vm.$nextTick(); + it('shows the correct number of channels available message', async () => { + const store = makeAvailableChannelsPageStore(); + await renderComponent({ store }); - expect(wrapper.vm.$route.name).toBe('SELECT_CONTENT'); + await waitFor(() => { + expect(screen.getByTestId('available')).toHaveTextContent( + numChannelsAvailable$({ count: 4 }), + ); }); }); }); From 2daa41e5b51a6f0f4a7863d24e524a2c666f4b8f Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Sun, 2 Aug 2026 14:37:48 +0000 Subject: [PATCH 08/13] test: add filter visibility and on-device indicator tests for AvailableChannelsPage --- .../__tests__/AvailableChannelsPage.spec.js | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js index 3f1d65e3287..0250c51fca4 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js @@ -1,8 +1,9 @@ -import { render, screen, waitFor } from '@testing-library/vue'; +import { render, screen, waitFor, within } from '@testing-library/vue'; import VueRouter from 'vue-router'; import { createTranslator } from 'kolibri/utils/i18n'; import AvailableChannelsPage from '../AvailableChannelsPage'; import FilteredChannelListContainer from '../ManageContentPage/FilteredChannelListContainer'; +import WithImportDetails from '../ManageContentPage/ChannelPanel/WithImportDetails'; import { makeAvailableChannelsPageStore } from '../../__tests__/utils/makeStore'; import { PageNames } from '../../constants'; @@ -14,10 +15,12 @@ const { channelTokenButtonLabel$, importResourcesHeader$ } = createTranslator( AvailableChannelsPage.name, AvailableChannelsPage.$trs, ); + const { numChannelsAvailable$ } = createTranslator( FilteredChannelListContainer.name, FilteredChannelListContainer.$trs, ); +const { onYourDevice$ } = createTranslator(WithImportDetails.name, WithImportDetails.$trs); function createRouter() { return new VueRouter({ @@ -82,4 +85,37 @@ describe('AvailableChannelsPage', () => { ); }); }); + + it('if there are no channels, then filters do not appear', async () => { + const store = makeAvailableChannelsPageStore(); + store.commit('manageContent/wizard/SET_AVAILABLE_CHANNELS', []); + await renderComponent({ store }); + + await waitFor(() => { + expect(screen.getByTestId('available')).toBeInTheDocument(); + }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('shows the "on device" indicator only for channels that are installed', async () => { + const store = makeAvailableChannelsPageStore(); + await renderComponent({ store }); + + await waitFor(() => { + expect(screen.getByTestId('available')).toBeInTheDocument(); + }); + + // Scoped check: find each channel's own box, then look only inside it + function isOnDevice(channelName) { + const channelBox = screen.getByText(channelName).closest('.channel-list-item'); + return within(channelBox).queryByText(onYourDevice$()) !== null; + } + + // Awesome and Kaetze are installed (available: true in fixture data) + expect(isOnDevice('Awesome Channel')).toBe(true); + expect(isOnDevice('Kaetze Channel')).toBe(true); + // Bird (available: false) and Hunden (not in installed channelList) should not show it + expect(isOnDevice('Bird Channel')).toBe(false); + expect(isOnDevice('Hunden Channel')).toBe(false); + }); }); From 6903f9f4aa31975ec02dd77156f6f43d7cd6ab5f Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 4 Aug 2026 15:38:01 +0000 Subject: [PATCH 09/13] fix: wait for Sortable instantiation before accessing mock results in RearrangeChannelsPage tests --- .../__tests__/RearrangeChannelsPage.spec.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js index 0132ecbbcd1..1b0bd0a6d5f 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/RearrangeChannelsPage.spec.js @@ -90,12 +90,17 @@ describe('RearrangeChannelsPage', () => { const Sortable = require('sortablejs'); await renderComponent(); await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); - + // DragContainer creates the Sortable instance inside mounted() -> $nextTick(), + // which is one tick after the channel list itself renders. On a slow/busy CI + // machine this can still be pending when the line above resolves, so we wait + // for it explicitly instead of assuming it already happened. + await waitFor(() => { + expect(Sortable.mock.results.length).toBeGreaterThan(0); + }); // Simulate the drag ending by calling the onEnd callback SortableJS // would normally call itself once the pointer is released. const { onEnd } = Sortable.mock.results[0].value.options; onEnd({ oldIndex: 0, newIndex: 1, item: document.createElement('div') }); - await waitFor(() => { expect(createSnackbar).toHaveBeenCalledWith(successNotification$()); }); @@ -129,6 +134,13 @@ describe('RearrangeChannelsPage', () => { await renderComponent(); await waitFor(() => screen.getByText(MOCK_CHANNELS[0].name)); + // See comment in the "successful @sort event" test above — Sortable is + // constructed one tick after the channel list renders, so we wait for it + // explicitly to avoid a race on slow/busy CI machines. + await waitFor(() => { + expect(Sortable.mock.results.length).toBeGreaterThan(0); + }); + const { onEnd } = Sortable.mock.results[0].value.options; onEnd({ oldIndex: 0, newIndex: 1, item: document.createElement('div') }); From 7db38c9589f9f59538a26dfefc1337e57f2a1b77 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Tue, 4 Aug 2026 15:41:42 +0000 Subject: [PATCH 10/13] test: add language filter options test for AvailableChannelsPage --- .../__tests__/AvailableChannelsPage.spec.js | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js index 0250c51fca4..5c2f6f204f9 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js @@ -16,7 +16,7 @@ const { channelTokenButtonLabel$, importResourcesHeader$ } = createTranslator( AvailableChannelsPage.$trs, ); -const { numChannelsAvailable$ } = createTranslator( +const { numChannelsAvailable$, allLanguages$ } = createTranslator( FilteredChannelListContainer.name, FilteredChannelListContainer.$trs, ); @@ -118,4 +118,23 @@ describe('AvailableChannelsPage', () => { expect(isOnDevice('Bird Channel')).toBe(false); expect(isOnDevice('Hunden Channel')).toBe(false); }); + it('shows the correct language filter options', async () => { + const store = makeAvailableChannelsPageStore(); + await renderComponent({ store }); + + await waitFor(() => { + expect(screen.getByTestId('available')).toBeInTheDocument(); + }); + + // The dropdown's currently-selected value is also rendered separately from the + // options list, so we scope our search to just the options list to avoid duplicates. + const optionsList = document.querySelector('.ui-select-options'); + // These language names come from fixture data (lang_name), not app translations, + // so we store them in variables rather than passing literals directly to getByText. + const englishLanguageName = 'English'; + const germanLanguageName = 'German'; + expect(within(optionsList).getByText(allLanguages$())).toBeInTheDocument(); + expect(within(optionsList).getByText(englishLanguageName)).toBeInTheDocument(); + expect(within(optionsList).getByText(germanLanguageName)).toBeInTheDocument(); + }); }); From 1733fe53c72d1e71bf69756964048e40f684d1a3 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmad Date: Sat, 8 Aug 2026 05:01:52 +0000 Subject: [PATCH 11/13] test: add language filter interaction test for AvailableChannelsPage --- .../__tests__/AvailableChannelsPage.spec.js | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js index 5c2f6f204f9..9341f22ae73 100644 --- a/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js +++ b/kolibri/plugins/device/frontend/views/__tests__/AvailableChannelsPage.spec.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from '@testing-library/vue'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/vue'; import VueRouter from 'vue-router'; import { createTranslator } from 'kolibri/utils/i18n'; import AvailableChannelsPage from '../AvailableChannelsPage'; @@ -137,4 +137,41 @@ describe('AvailableChannelsPage', () => { expect(within(optionsList).getByText(englishLanguageName)).toBeInTheDocument(); expect(within(optionsList).getByText(germanLanguageName)).toBeInTheDocument(); }); + + it('filters the channel list when a language filter option is selected', async () => { + const store = makeAvailableChannelsPageStore(); + await renderComponent({ store }); + + await waitFor(() => { + expect(screen.getByTestId('available')).toBeInTheDocument(); + }); + + // KSelect is a custom dropdown, not a native