feat(glass): refine material response and rendering lifecycle (#596)

This commit is contained in:
InfinityPacer
2026-07-29 11:19:12 +08:00
committed by GitHub
parent 90d626e7c6
commit 287cbc76ae
29 changed files with 4145 additions and 632 deletions
+172 -7
View File
@@ -2,6 +2,8 @@
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
createGlassWallpaperSourceCache,
getGlassWallpaperPreparationKey,
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
@@ -39,16 +41,40 @@ const props = defineProps<{
previousWallpaperUrl: string
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
pendingWallpaperUrl?: string
/** 单调递增的壁纸准备事务版本;相同 URL 的旧回执不得完成新事务。 */
pendingWallpaperRevision?: number
/** 父层已完成可见图片预载,允许两个 context 在同一绘制帧提交该 revision。 */
activateWallpaperRevision?: number
}>()
const timingWindow = window as typeof window & {
__glassPerformanceProbeEnabled?: boolean
__glassLaunchTimings?: Array<{ detail?: string; stage: string; time: number }>
}
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings ??= []
timingWindow.__glassLaunchTimings.push({
stage: 'optical-layer-setup',
time: performance.now(),
})
}
const emit = defineEmits<{
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
wallpaperPrepared: [url: string]
wallpaperPrepared: [url: string, revision: number]
/** 任一 context 无法准备当前 revision;父层必须立即取消整笔事务。 */
wallpaperPreparationFailed: [url: string, revision: number]
/** fixed 与 scroll renderer 均已消费 prepared 纹理并切到活动槽。 */
wallpaperActivated: [url: string, revision: number, startedAt: number]
/** 任一 context 提交失败;父层必须取消该 revision。 */
wallpaperActivationFailed: [url: string, revision: number]
}>()
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource()
const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
@@ -66,8 +92,10 @@ const fixedRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'fixed',
syncDocumentState: false,
})
@@ -89,8 +117,10 @@ const scrollRenderer = useGlassOpticalRenderer({
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
wallpaperSourceCache,
previousWallpaperUrl: () => props.previousWallpaperUrl,
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
pendingWallpaperRevision: () => props.pendingWallpaperRevision ?? 0,
surfaceSpace: 'scroll',
syncDocumentState: false,
})
@@ -107,16 +137,151 @@ watchEffect(() => {
: 'fallback'
setGlassRendererState(rendererState, state)
})
watchEffect(() => {
const url = props.pendingWallpaperUrl
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
emit('wallpaperPrepared', url)
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
timingWindow.__glassLaunchTimings?.push({
detail: state,
stage: 'optical-layer-state',
time: performance.now(),
})
}
})
let lastPreparedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const prepared =
Boolean(url) &&
revision > 0 &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (prepared && acknowledgement !== lastPreparedAcknowledgement) {
lastPreparedAcknowledgement = acknowledgement
emit('wallpaperPrepared', url, revision)
}
})
let lastPreparationFailedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl ?? ''
const revision = props.pendingWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url)
const acknowledgement = `${revision}:${preparationKey}:${url}`
const failed = [fixedRenderer, scrollRenderer].some(
renderer =>
renderer.failedWallpaperUrl.value === url &&
renderer.failedWallpaperRevision.value === revision &&
renderer.failedWallpaperPreparationKey.value === preparationKey,
)
if (url && revision > 0 && failed && acknowledgement !== lastPreparationFailedAcknowledgement) {
lastPreparationFailedAcknowledgement = acknowledgement
emit('wallpaperPreparationFailed', url, revision)
}
})
let activationFrame: number | null = null
let scheduledActivation = ''
let lastActivatedAcknowledgement = ''
let lastFailedAcknowledgement = ''
function rollbackWallpaperActivation(url: string, revision: number) {
for (const renderer of [fixedRenderer, scrollRenderer]) {
try {
renderer.rollbackPreparedWallpaperActivation(url, revision)
} catch {
// 两个 context 独立回滚;一个异常不得阻止另一个恢复并通知父层取消。
}
}
}
watchEffect(() => {
const url = props.pendingWallpaperUrl
const revision = props.pendingWallpaperRevision ?? 0
const activationRevision = props.activateWallpaperRevision ?? 0
const preparationKey = getGlassWallpaperPreparationKey(props.appearance, props.quality, props.routeKey, url ?? '')
const acknowledgement = `${revision}:${preparationKey}:${url}`
const canActivate =
Boolean(url) &&
revision > 0 &&
activationRevision === revision &&
fixedRenderer.state.value === 'ready' &&
scrollRenderer.state.value === 'ready' &&
fixedRenderer.preparedWallpaperUrl.value === url &&
fixedRenderer.preparedWallpaperRevision.value === revision &&
fixedRenderer.preparedWallpaperPreparationKey.value === preparationKey &&
scrollRenderer.preparedWallpaperUrl.value === url &&
scrollRenderer.preparedWallpaperRevision.value === revision &&
scrollRenderer.preparedWallpaperPreparationKey.value === preparationKey
if (
!canActivate ||
acknowledgement === lastActivatedAcknowledgement ||
acknowledgement === lastFailedAcknowledgement ||
acknowledgement === scheduledActivation
) {
return
}
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
scheduledActivation = acknowledgement
activationFrame = requestAnimationFrame(startedAt => {
activationFrame = null
scheduledActivation = ''
const currentUrl = props.pendingWallpaperUrl ?? ''
const currentRevision = props.pendingWallpaperRevision ?? 0
const currentPreparationKey = getGlassWallpaperPreparationKey(
props.appearance,
props.quality,
props.routeKey,
currentUrl,
)
const currentAcknowledgement = `${currentRevision}:${currentPreparationKey}:${currentUrl}`
if (
currentAcknowledgement !== acknowledgement ||
props.activateWallpaperRevision !== currentRevision ||
!fixedRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey) ||
!scrollRenderer.canActivatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey)
) {
return
}
try {
const fixedActivated = fixedRenderer.activatePreparedWallpaper(
currentUrl,
currentRevision,
currentPreparationKey,
startedAt,
)
const scrollActivated =
fixedActivated &&
scrollRenderer.activatePreparedWallpaper(currentUrl, currentRevision, currentPreparationKey, startedAt)
if (!fixedActivated || !scrollActivated) {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
} catch {
rollbackWallpaperActivation(currentUrl, currentRevision)
lastFailedAcknowledgement = acknowledgement
emit('wallpaperActivationFailed', currentUrl, currentRevision)
return
}
lastActivatedAcknowledgement = acknowledgement
emit('wallpaperActivated', currentUrl, currentRevision, startedAt)
})
})
onScopeDispose(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
setGlassRendererState(rendererState, 'fallback')
})
</script>
@@ -1,10 +1,30 @@
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
const rendererResults = vi.hoisted(
() =>
[] as Array<{
activatePreparedWallpaper: ReturnType<typeof vi.fn>
activeWallpaperPreparationKey: { value: string }
activeWallpaperRevision: { value: number }
activeWallpaperUrl: { value: string }
canActivatePreparedWallpaper: ReturnType<typeof vi.fn>
failedWallpaperPreparationKey: { value: string }
failedWallpaperRevision: { value: number }
failedWallpaperUrl: { value: string }
preparedWallpaperPreparationKey: { value: string }
preparedWallpaperRevision: { value: number }
preparedWallpaperUrl: { value: string }
renderedFrames: { value: number }
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
state: { value: string }
}>,
)
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
const setRendererState = vi.hoisted(() =>
vi.fn((state: { value: string }, value: string) => {
state.value = value
@@ -12,21 +32,82 @@ const setRendererState = vi.hoisted(() =>
)
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
createGlassWallpaperSourceCache: vi.fn(() => wallpaperSourceCache),
getGlassWallpaperPreparationKey: vi.fn(
(appearance: string, quality: string, routeKey: string, url: string) =>
`${appearance}:${quality}:${routeKey}:${url}`,
),
setGlassRendererState: setRendererState,
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
rendererCalls.push(options)
let rollbackState = {
preparationKey: '',
revision: 0,
url: '',
}
return {
const result = {
activeWallpaperPreparationKey: ref(''),
activeWallpaperRevision: ref(0),
activeWallpaperUrl: ref(''),
failedWallpaperPreparationKey: ref(''),
failedWallpaperRevision: ref(0),
failedWallpaperUrl: ref(''),
preparedWallpaperPreparationKey: ref(''),
preparedWallpaperRevision: ref(0),
preparedWallpaperUrl: ref(''),
renderedFrames: ref(0),
state: ref('ready'),
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
return (
result.state.value === 'ready' &&
result.preparedWallpaperUrl.value === url &&
result.preparedWallpaperRevision.value === revision &&
result.preparedWallpaperPreparationKey.value === preparationKey
)
}),
activatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
if (!result.canActivatePreparedWallpaper(url, revision, preparationKey)) return false
rollbackState = {
preparationKey: result.activeWallpaperPreparationKey.value,
revision: result.activeWallpaperRevision.value,
url: result.activeWallpaperUrl.value,
}
result.preparedWallpaperUrl.value = ''
result.preparedWallpaperRevision.value = 0
result.preparedWallpaperPreparationKey.value = ''
result.activeWallpaperUrl.value = url
result.activeWallpaperRevision.value = revision
result.activeWallpaperPreparationKey.value = preparationKey
return true
}),
rollbackPreparedWallpaperActivation: vi.fn((url: string, revision: number) => {
if (result.activeWallpaperUrl.value !== url || result.activeWallpaperRevision.value !== revision) return false
result.activeWallpaperUrl.value = rollbackState.url
result.activeWallpaperRevision.value = rollbackState.revision
result.activeWallpaperPreparationKey.value = rollbackState.preparationKey
return true
}),
}
rendererResults.push(result)
return result
}),
}))
afterEach(() => {
vi.unstubAllGlobals()
})
describe('GlassOpticalLayer', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => {
rendererCalls.length = 0
rendererResults.length = 0
setRendererState.mockClear()
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
@@ -52,6 +133,7 @@ describe('GlassOpticalLayer', () => {
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
@@ -64,4 +146,241 @@ describe('GlassOpticalLayer', () => {
wrapper.unmount()
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
})
it('activates matching resource bundles in one shared animation frame', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 31
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 0,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 7,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.preparedWallpaperRevision.value = 7
fixedRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.preparedWallpaperRevision.value = 7
scrollRenderer.preparedWallpaperPreparationKey.value = 'stale-key'
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toBeUndefined()
scrollRenderer.preparedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPrepared')).toEqual([['/wallpaper-next.jpg', 7]])
await wrapper.setProps({ activateWallpaperRevision: 7 })
await nextTick()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(420)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledWith('/wallpaper-next.jpg', 7, preparationKey, 420)
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 7, 420]])
expect(fixedRenderer.activeWallpaperRevision.value).toBe(7)
expect(scrollRenderer.activeWallpaperRevision.value).toBe(7)
wrapper.unmount()
})
it('reports one matching preparation failure so the parent can cancel the pending revision', async () => {
rendererCalls.length = 0
rendererResults.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 10,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 9
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toBeUndefined()
scrollRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
scrollRenderer.failedWallpaperRevision.value = 10
scrollRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
fixedRenderer.failedWallpaperUrl.value = '/wallpaper-next.jpg'
fixedRenderer.failedWallpaperRevision.value = 10
fixedRenderer.failedWallpaperPreparationKey.value = preparationKey
await nextTick()
expect(wrapper.emitted('wallpaperPreparationFailed')).toEqual([['/wallpaper-next.jpg', 10]])
wrapper.unmount()
})
it('does not partially commit while either context is unavailable', async () => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
activationCallback = callback
return 32
})
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 8,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 8,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'high',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:high:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 8
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
scrollRenderer.state.value = 'fallback'
await nextTick()
expect(requestFrame).not.toHaveBeenCalled()
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
scrollRenderer.state.value = 'ready'
await nextTick()
expect(requestFrame).toHaveBeenCalledOnce()
;(activationCallback as FrameRequestCallback | null)?.(640)
await nextTick()
expect(fixedRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(scrollRenderer.activatePreparedWallpaper).toHaveBeenCalledOnce()
expect(wrapper.emitted('wallpaperActivated')).toEqual([['/wallpaper-next.jpg', 8, 640]])
wrapper.unmount()
})
it.each([
[
'returns false',
(renderer: (typeof rendererResults)[number]) => renderer.activatePreparedWallpaper.mockReturnValueOnce(false),
],
[
'throws',
(renderer: (typeof rendererResults)[number]) =>
renderer.activatePreparedWallpaper.mockImplementationOnce(() => {
throw new Error('context commit failed')
}),
],
])('rolls both contexts back when the second activation %s', async (_, failScrollActivation) => {
rendererCalls.length = 0
rendererResults.length = 0
let activationCallback: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
activationCallback = callback
return 33
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
activateWallpaperRevision: 9,
deformationStrength: 50,
flowStrength: 50,
pendingWallpaperRevision: 9,
pendingWallpaperUrl: '/wallpaper-next.jpg',
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper-current.jpg',
},
})
const [fixedRenderer, scrollRenderer] = rendererResults
const preparationKey = 'frosted:balanced:/dashboard:/wallpaper-next.jpg'
for (const renderer of rendererResults) {
renderer.activeWallpaperUrl.value = '/wallpaper-current.jpg'
renderer.preparedWallpaperUrl.value = '/wallpaper-next.jpg'
renderer.preparedWallpaperRevision.value = 9
renderer.preparedWallpaperPreparationKey.value = preparationKey
}
failScrollActivation(scrollRenderer)
await nextTick()
;(activationCallback as FrameRequestCallback | null)?.(720)
await nextTick()
expect(fixedRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(scrollRenderer.rollbackPreparedWallpaperActivation).toHaveBeenCalledWith('/wallpaper-next.jpg', 9)
expect(fixedRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(scrollRenderer.activeWallpaperUrl.value).toBe('/wallpaper-current.jpg')
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
expect(wrapper.emitted('wallpaperActivationFailed')).toEqual([['/wallpaper-next.jpg', 9]])
wrapper.unmount()
})
})