Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions __tests__/component/Viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock('@nextcloud/image-editor', async () => {

import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Permission, registerFileAction } from '@nextcloud/files'
import { useViewerSettings } from '../../lib/composables/useViewerSettings.ts'
import { restoreTitle } from '../../lib/utils/documentTitle.ts'
import { getViewer } from '../../lib/viewer.ts'
import { makeFile, makeHandler } from '../factories.ts'
Expand Down Expand Up @@ -522,6 +523,47 @@ describe('comparing two files', () => {
})
})

describe('the viewer settings', () => {
it('set how long the slideshow shows each file', async () => {
const { vm, wrapper, modalProps } = mountViewer([imageHandler()])
const file = makeFile({ mime: 'image/jpeg' })
await vm.open([file], file)
await wrapper.vm.$nextTick()
expect(modalProps().slideshowDelay).toBe(5000)

await useViewerSettings().setSlideshowDelay(10)
await wrapper.vm.$nextTick()
expect(modalProps().slideshowDelay).toBe(10000)
await useViewerSettings().setSlideshowDelay(5)
})

// Escape reaches the viewer too until the dialog has taken the focus
it('keep the viewer open while they are', async () => {
const { vm, wrapper, modalProps } = mountViewer([imageHandler()])
const file = makeFile({ mime: 'image/jpeg' })
await vm.open([file], file)
await wrapper.vm.$nextTick()
expect(modalProps().noClose).toBe(false)

await wrapper.find('.viewer__settings').trigger('click')
expect(modalProps().noClose).toBe(true)
})

it.each([
['the button at the bottom', (wrapper: VueWrapper) => wrapper.find('.viewer__settings').trigger('click')],
['?', () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }))],
])('open on %s', async (_, open) => {
const { vm, wrapper } = mountViewer([imageHandler()])
const file = makeFile({ mime: 'image/jpeg' })
await vm.open([file], file)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'ViewerSettings' }).exists()).toBe(false)

await open(wrapper)
await vi.waitFor(() => expect(wrapper.findComponent({ name: 'ViewerSettings' }).exists()).toBe(true))
})
})

describe('Viewer sidebar', () => {
const sidebarButton = (wrapper: ReturnType<typeof mountViewer>['wrapper']) => wrapper.findAll('.nc-action-button-stub').find((button) => button.text().includes('Open sidebar'))

Expand Down
68 changes: 68 additions & 0 deletions __tests__/component/ViewerSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import ViewerSettings from '../../lib/components/ViewerSettings.vue'
import { useViewerSettings } from '../../lib/composables/useViewerSettings.ts'

const NcSelectStub = defineComponent({
name: 'NcSelect',
props: { options: { type: Array, default: () => [] }, modelValue: { type: Object, default: null } },
emits: ['update:modelValue'],
template: '<div class="nc-select-stub" />',
})

/** The settings dialog, with only the delay dropdown rendered */
function mountSettings() {
return mount(ViewerSettings, {
props: { open: true },
global: {
stubs: {
NcAppSettingsDialog: { template: '<div><slot /></div>' },
NcAppSettingsSection: { template: '<div><slot /></div>' },
NcAppSettingsShortcutsSection: true,
NcSelect: NcSelectStub,
},
},
})
}

afterEach(async () => {
await useViewerSettings().setSlideshowDelay(5)
})

describe('the slideshow delay dropdown', () => {
it('offers the delays, with the current one picked', () => {
const select = mountSettings().findComponent(NcSelectStub)

expect(select.props('options')).toEqual([
{ id: 3, label: '3 seconds' },
{ id: 5, label: '5 seconds' },
{ id: 10, label: '10 seconds' },
{ id: 30, label: '30 seconds' },
])
expect(select.props('modelValue')).toEqual({ id: 5, label: '5 seconds' })
})

// The server takes any delay from 1 to 60 seconds
it('lists the current delay too when it is none of the offered ones', async () => {
await useViewerSettings().setSlideshowDelay(7)
const select = mountSettings().findComponent(NcSelectStub)

expect((select.props('options') as { id: number }[]).map((option) => option.id)).toEqual([3, 5, 7, 10, 30])
expect(select.props('modelValue')).toEqual({ id: 7, label: '7 seconds' })
})

it('saves the picked delay', async () => {
const select = mountSettings().findComponent(NcSelectStub)

select.vm.$emit('update:modelValue', { id: 30, label: '30 seconds' })
await select.vm.$nextTick()

expect(useViewerSettings().slideshowDelay.value).toBe(30)
expect(select.props('modelValue')).toEqual({ id: 30, label: '30 seconds' })
})
})
2 changes: 2 additions & 0 deletions __tests__/component/mountViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const NcModalStub = defineComponent({
disableSwipe: { type: Boolean, default: false },
slideshowPaused: { type: Boolean, default: false },
slideshowRunning: { type: Boolean, default: false },
slideshowDelay: { type: Number, default: 5000 },
noClose: { type: Boolean, default: false },
lightBackdrop: { type: Boolean, default: false },
inlineActions: { type: Number, default: 0 },
additionalTrapElements: { type: Array, default: () => [] },
Expand Down
89 changes: 89 additions & 0 deletions __tests__/mediaVolume.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, useTemplateRef } from 'vue'

const put = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/initial-state', () => ({ loadState: () => ({ slideshow_delay: 5, volume: 40, muted: true }) }))
vi.mock('@nextcloud/axios', () => ({ default: { put } }))
vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() }))

/** A player playing at the volume the user keeps, once it has its element */
async function mountPlayer() {
// Fresh settings each time: they are kept for the whole page
vi.resetModules()
const { useMediaVolume } = await import('../lib/composables/useMediaVolume.ts')
const wrapper = mount(defineComponent({
setup() {
useMediaVolume(useTemplateRef<HTMLMediaElement>('media'))
return () => h('audio', { ref: 'media' })
},
}))
await nextTick()
return { wrapper, media: wrapper.element as HTMLAudioElement }
}

/**
* Change the volume the way the player's controls do
*
* @param media - The element
* @param volume - From 0 to 1
* @param muted - Whether it is muted
*/
function change(media: HTMLMediaElement, volume: number, muted = media.muted) {
media.volume = volume
media.muted = muted
media.dispatchEvent(new Event('volumechange'))
}

beforeEach(() => {
vi.useFakeTimers()
put.mockReset()
put.mockResolvedValue({})
})

afterEach(() => {
vi.useRealTimers()
})

describe('the volume of a player', () => {
it('starts where the user left it', async () => {
const { media } = await mountPlayer()

expect(media.volume).toBe(0.4)
expect(media.muted).toBe(true)
})

it('is kept once the slider stops, not on every step', async () => {
const { media } = await mountPlayer()

change(media, 0.5)
change(media, 0.6)
change(media, 0.7)
expect(put).not.toHaveBeenCalled()

await vi.advanceTimersByTimeAsync(500)
expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/volume'), { value: 70 })
})

it('keeps muting apart from the volume', async () => {
const { media } = await mountPlayer()

change(media, 0.4, false)
await vi.advanceTimersByTimeAsync(500)

expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/muted'), { value: false })
})

it('keeps the last change when the viewer closes before it is saved', async () => {
const { wrapper, media } = await mountPlayer()

change(media, 0.2)
wrapper.unmount()

expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/volume'), { value: 20 })
})
})
95 changes: 95 additions & 0 deletions __tests__/viewerSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const stored = vi.hoisted(() => ({ value: null as { slideshow_delay: number, volume: number, muted: boolean } | null }))
const put = vi.hoisted(() => vi.fn())
const showError = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/initial-state', () => ({ loadState: (app: string, key: string, fallback: unknown) => (app === 'viewer' && key === 'config' ? stored.value : null) ?? fallback }))
vi.mock('@nextcloud/axios', () => ({ default: { put } }))
vi.mock('@nextcloud/dialogs', () => ({ showError }))

/**
* The settings as a page with these stored settings sees them
*
* @param settings - What the server handed the page, nothing for a guest
*/
async function settingsWith(settings: { slideshow_delay: number, volume: number, muted: boolean } | null) {
stored.value = settings
vi.resetModules()
const { useViewerSettings } = await import('../lib/composables/useViewerSettings.ts')
return useViewerSettings()
}

/** What the server hands a user who never changed anything */
const STORED = { slideshow_delay: 5, volume: 100, muted: false }

beforeEach(() => {
put.mockReset()
put.mockResolvedValue({})
showError.mockReset()
})

afterEach(() => {
stored.value = null
})

describe('the slideshow delay', () => {
it('is 5 seconds unless the user chose otherwise', async () => {
expect((await settingsWith(null)).slideshowDelay.value).toBe(5)
expect((await settingsWith({ ...STORED, slideshow_delay: 10 })).slideshowDelay.value).toBe(10)
})

it('is kept on the server', async () => {
const settings = await settingsWith(STORED)

await settings.setSlideshowDelay(20)

expect(settings.slideshowDelay.value).toBe(20)
expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/slideshow_delay'), { value: 20 })
})

// No settings handed over: a guest, or a server that keeps none
it('is only kept on the page where there is nowhere to save it', async () => {
const settings = await settingsWith(null)

await settings.setSlideshowDelay(20)

expect(settings.slideshowDelay.value).toBe(20)
expect(put).not.toHaveBeenCalled()
})

it('goes back to what it was when it cannot be saved, and says so', async () => {
put.mockRejectedValue(new Error('offline'))
const settings = await settingsWith({ ...STORED, slideshow_delay: 10 })

await settings.setSlideshowDelay(20)

expect(settings.slideshowDelay.value).toBe(10)
expect(showError).toHaveBeenCalledWith('Could not save your viewer settings')
})
})

describe('the volume', () => {
it('is full and not muted unless the user chose otherwise', async () => {
const settings = await settingsWith(null)
expect(settings.volume.value).toBe(100)
expect(settings.muted.value).toBe(false)

const chosen = await settingsWith({ ...STORED, volume: 40, muted: true })
expect(chosen.volume.value).toBe(40)
expect(chosen.muted.value).toBe(true)
})

it('is kept on the server, muted or not', async () => {
const settings = await settingsWith(STORED)

await settings.setVolume(40)
await settings.setMuted(true)

expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/volume'), { value: 40 })
expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/muted'), { value: true })
})
})
44 changes: 44 additions & 0 deletions e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect, test } from '@playwright/test'
import { ViewerPage } from './support/viewer.ts'

test.describe('The viewer settings', () => {
test('open from the button at the bottom, and list the shortcuts', async ({ page }) => {
const viewer = new ViewerPage(page)
await viewer.open('photo.jpg')
await viewer.waitForOpen()

await viewer.container.getByRole('button', { name: 'Viewer settings' }).click()
const settings = page.locator('.app-settings')
await expect(settings).toBeVisible()
await expect(settings.getByText('Download')).toBeVisible()
await expect(settings.locator('.vs__selected')).toHaveText('5 seconds')

// Not before the dialog has taken the focus, which would close it again
await expect(settings.locator(':focus')).toHaveCount(1)
await settings.getByRole('combobox').click()
// By its text: NcSelect splits a label in two to ellipsise its middle
await page.getByRole('option').filter({ hasText: '10 seconds' }).click()
await expect(settings.locator('.vs__selected')).toHaveText('10 seconds')
})

// The dialog sits over the viewer: Escape closes it, and only it
test('open on ?, and close on Escape without closing the viewer', async ({ page }) => {
const viewer = new ViewerPage(page)
await viewer.open('photo.jpg')
await viewer.waitForOpen()

await page.keyboard.press('?')
const settings = page.locator('.app-settings')
await expect(settings).toBeVisible()
// Escape goes to the dialog once it holds the focus
await expect(settings.locator(':focus')).toHaveCount(1)

await page.keyboard.press('Escape')
await expect(settings).toHaveCount(0)
await expect(viewer.container).toBeVisible()
})
})
Loading
Loading