mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat(glass): refine material response and rendering lifecycle (#596)
This commit is contained in:
@@ -11,10 +11,15 @@ import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
GLASS_OPTICAL_STRENGTH_MIN,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalPresetParametersWithOverrides,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalParameters,
|
||||
type GlassOpticalPreset,
|
||||
type GlassOpticalPresetOverrides,
|
||||
} from '@/utils/glassOptics'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -32,11 +37,13 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
||||
@@ -68,6 +75,7 @@ watch(
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||
draftQuality.value = settings.value.glassQuality
|
||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
||||
@@ -112,7 +120,7 @@ function updateAppearance(value: unknown) {
|
||||
if (value !== 'clear' && value !== 'tinted' && value !== 'frosted') return
|
||||
|
||||
draftAppearance.value = value
|
||||
previewGlassSettings({ glassAppearance: value })
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 仅允许面板声明的质量档位进入待保存设置。 */
|
||||
@@ -121,15 +129,18 @@ function updateQuality(value: unknown) {
|
||||
if (!option) return
|
||||
|
||||
draftQuality.value = option.value
|
||||
previewGlassSettings({ glassQuality: option.value })
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
||||
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
@@ -137,13 +148,29 @@ function previewDraftParameters() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
|
||||
/** 读取草稿中当前显示的六参数。 */
|
||||
function getDraftParameters(): GlassOpticalParameters {
|
||||
return {
|
||||
deformation: draftDeformationStrength.value,
|
||||
flow: draftFlowStrength.value,
|
||||
reflection: draftReflectionStrength.value,
|
||||
transmission: draftTransmissionStrength.value,
|
||||
translation: draftTranslationStrength.value,
|
||||
transparency: draftTransparencyStrength.value,
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换方案时优先恢复该组合的草稿覆盖,没有覆盖才使用矩阵。 */
|
||||
function applyPreset(value: unknown) {
|
||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||
if (!availablePresets.value.includes(value)) return
|
||||
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
||||
draftPreset.value = value
|
||||
const effectivePreset = availablePresets.value.includes(value) ? value : 'natural'
|
||||
const parameters = getGlassOpticalPresetParametersWithOverrides(
|
||||
draftAppearance.value,
|
||||
draftQuality.value,
|
||||
effectivePreset,
|
||||
draftPresetOverrides.value,
|
||||
)
|
||||
draftPreset.value = effectivePreset
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
@@ -153,45 +180,66 @@ function applyPreset(value: unknown) {
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 用调整后的当前六参数覆盖当前材质、质量与方案组合。 */
|
||||
function updateDraftPresetOverride() {
|
||||
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
draftPresetOverrides.value = {
|
||||
...draftPresetOverrides.value,
|
||||
[key]: getDraftParameters(),
|
||||
}
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||
function updateTranslationStrength(value: unknown) {
|
||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||
function updateDeformationStrength(value: unknown) {
|
||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||
function updateFlowStrength(value: unknown) {
|
||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||
function updateReflectionStrength(value: unknown) {
|
||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
||||
function updateTransmissionStrength(value: unknown) {
|
||||
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||
function updateTransparencyStrength(value: unknown) {
|
||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
||||
updateDraftPresetOverride()
|
||||
}
|
||||
|
||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
||||
/** 删除当前组合覆盖并恢复该方案矩阵,不影响其他组合。 */
|
||||
function resetSettings() {
|
||||
applyPreset(activePreset.value)
|
||||
const key = getGlassOpticalPresetKey(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
const nextOverrides = { ...draftPresetOverrides.value }
|
||||
delete nextOverrides[key]
|
||||
draftPresetOverrides.value = nextOverrides
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, draftPreset.value)
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
draftTransmissionStrength.value = parameters.transmission
|
||||
draftTranslationStrength.value = parameters.translation
|
||||
draftTransparencyStrength.value = parameters.transparency
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||
@@ -206,6 +254,7 @@ async function saveSettings() {
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
@@ -224,7 +273,14 @@ onScopeDispose(cancelGlassPreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="100%" max-width="30rem" scrollable>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
width="100%"
|
||||
max-width="30rem"
|
||||
scrollable
|
||||
:fullscreen="display.smAndDown.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -415,14 +471,21 @@ onScopeDispose(cancelGlassPreview)
|
||||
</VCardText>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||
<VCardActions class="glass-settings-dialog__actions justify-center">
|
||||
<VBtn :slim="false" variant="outlined" prepend-icon="mdi-refresh" class="me-2" @click="resetSettings">
|
||||
{{ t('common.reset') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" :loading="isSaving" @click="saveSettings">
|
||||
<VBtn
|
||||
:slim="false"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="isSaving"
|
||||
@click="saveSettings"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -436,6 +499,11 @@ onScopeDispose(cancelGlassPreview)
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__actions {
|
||||
flex: none;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin: 0 0 10px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
|
||||
@@ -173,12 +173,7 @@ onBeforeMount(async () => {
|
||||
<template>
|
||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
@@ -213,7 +208,7 @@ onBeforeMount(async () => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -136,12 +136,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
@@ -163,7 +158,7 @@ onMounted(() => {
|
||||
/>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -3,6 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const dialogStub = {
|
||||
props: ['fullscreen'],
|
||||
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||
}
|
||||
const toggleStub = {
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
@@ -25,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 50,
|
||||
glassTransmissionStrength: 50,
|
||||
@@ -47,6 +52,10 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ smAndDown: { value: true } }),
|
||||
}))
|
||||
|
||||
describe('GlassSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cancelGlassPreview.mockClear()
|
||||
@@ -56,6 +65,7 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassPresetOverrides = {}
|
||||
mocks.settings.value.glassQuality = 'css'
|
||||
mocks.settings.value.glassReflectionStrength = 50
|
||||
mocks.settings.value.glassTransmissionStrength = 50
|
||||
@@ -68,8 +78,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -81,6 +92,8 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(sliders).toHaveLength(3)
|
||||
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
||||
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
|
||||
expect(wrapper.find('.dialog-stub').attributes('data-fullscreen')).toBe('true')
|
||||
expect(wrapper.find('.glass-settings-dialog__actions').classes()).toContain('justify-center')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
|
||||
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||
@@ -96,17 +109,37 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.settings.value.glassTransmissionStrength = 57
|
||||
mocks.settings.value.glassTranslationStrength = 57
|
||||
mocks.settings.value.glassTransparencyStrength = 55
|
||||
mocks.settings.value.glassPresetOverrides = {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 24,
|
||||
flow: 35,
|
||||
reflection: 46,
|
||||
transmission: 57,
|
||||
translation: 68,
|
||||
transparency: 79,
|
||||
},
|
||||
'frosted:high:liquid': {
|
||||
deformation: 65,
|
||||
flow: 61,
|
||||
reflection: 58,
|
||||
transmission: 57,
|
||||
translation: 57,
|
||||
transparency: 55,
|
||||
},
|
||||
}
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: {
|
||||
emits: ['click'],
|
||||
props: ['prependIcon'],
|
||||
template: '<button :data-icon="prependIcon" @click="$emit(\'click\')"><slot /></button>',
|
||||
props: ['prependIcon', 'slim', 'variant'],
|
||||
template:
|
||||
'<button :data-icon="prependIcon" :data-slim="String(slim)" :data-variant="variant" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VDivider: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -115,17 +148,33 @@ describe('GlassSettingsDialog', () => {
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const resetButton = wrapper.find('[data-icon="mdi-refresh"]')
|
||||
const saveButton = wrapper.find('[data-icon="mdi-content-save"]')
|
||||
|
||||
expect(resetButton.attributes('data-slim')).toBe('false')
|
||||
expect(saveButton.attributes('data-slim')).toBe('false')
|
||||
expect(saveButton.attributes('data-variant')).toBe('elevated')
|
||||
await resetButton.trigger('click')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassDeformationStrength: 82,
|
||||
glassFlowStrength: 80,
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 66,
|
||||
glassFlowStrength: 64,
|
||||
glassPreset: 'liquid',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 24,
|
||||
flow: 35,
|
||||
reflection: 46,
|
||||
transmission: 57,
|
||||
translation: 68,
|
||||
transparency: 79,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 31,
|
||||
glassTransmissionStrength: 58,
|
||||
glassTranslationStrength: 54,
|
||||
glassTransparencyStrength: 46,
|
||||
glassTransmissionStrength: 47,
|
||||
glassTranslationStrength: 43,
|
||||
glassTransparencyStrength: 32,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -135,8 +184,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -149,7 +199,27 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
|
||||
await sliders[2].setValue('86')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:css:natural': {
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 86,
|
||||
transmission: 50,
|
||||
translation: 50,
|
||||
transparency: 50,
|
||||
},
|
||||
},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 86,
|
||||
glassTransmissionStrength: 50,
|
||||
glassTranslationStrength: 50,
|
||||
glassTransparencyStrength: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('hides preset choices in standard quality', () => {
|
||||
@@ -157,8 +227,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -170,7 +241,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted after slider adjustments', async () => {
|
||||
it('keeps the selected preset highlighted and records its combination override', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassPreset = 'glide'
|
||||
mocks.settings.value.glassTransparencyStrength = 61
|
||||
@@ -178,10 +249,11 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -199,6 +271,21 @@ describe('GlassSettingsDialog', () => {
|
||||
await sliders[0].setValue('77')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
glassPreset: 'glide',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:glide': {
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 50,
|
||||
transmission: 50,
|
||||
translation: 50,
|
||||
transparency: 77,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes and previews all six independent slider values', async () => {
|
||||
@@ -207,8 +294,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
@@ -227,12 +315,28 @@ describe('GlassSettingsDialog', () => {
|
||||
await sliders[4].setValue('68.7')
|
||||
await sliders[5].setValue('62.2')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledTimes(6)
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 69,
|
||||
glassFlowStrength: 62,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 69,
|
||||
flow: 62,
|
||||
reflection: 74,
|
||||
transmission: 78,
|
||||
translation: 84,
|
||||
transparency: 91,
|
||||
},
|
||||
},
|
||||
glassQuality: 'balanced',
|
||||
glassReflectionStrength: 74,
|
||||
glassTransmissionStrength: 78,
|
||||
glassTranslationStrength: 84,
|
||||
glassTransparencyStrength: 91,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -242,8 +346,9 @@ describe('GlassSettingsDialog', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user