mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
af8f55976f
Add a Style setting beside mode, accent, radius and background. Nostalgia restyles the app through tokens only (neutral surface palettes, system type, flat shell, top glow) and pins background, opacity and brightness. Add a top glow reach slider for both styles. Tidy the theme layer first: charts read live tokens, bare `rounded` follows the radius scale, one shared sign-in toolbar. Also carries the settings page header redesign.
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { reactive, ref } from 'vue'
|
|
import type { ThemePreferences } from '@bookorbit/types'
|
|
|
|
const user = ref<{ settings?: { syncThemePreferences?: boolean } } | null>(null)
|
|
const toastError = vi.fn<(message: string) => void>()
|
|
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
|
|
|
function createThemeStore() {
|
|
return reactive({
|
|
theme: 'dark' as ThemePreferences['theme'],
|
|
accent: 'blue' as ThemePreferences['accent'],
|
|
radius: 'rounded' as ThemePreferences['radius'],
|
|
background: 'vinyl' as ThemePreferences['background'],
|
|
brightness: 35,
|
|
surfaceOpacity: 92,
|
|
style: 'bookorbit' as NonNullable<ThemePreferences['style']>,
|
|
surfacePalette: 'ash' as NonNullable<ThemePreferences['surfacePalette']>,
|
|
glowReach: 100,
|
|
setTheme(nextTheme: ThemePreferences['theme']) {
|
|
this.theme = nextTheme
|
|
},
|
|
setAccent(nextAccent: ThemePreferences['accent']) {
|
|
this.accent = nextAccent
|
|
},
|
|
setRadius(nextRadius: ThemePreferences['radius']) {
|
|
this.radius = nextRadius
|
|
},
|
|
setBackground(nextBackground: ThemePreferences['background']) {
|
|
this.background = nextBackground
|
|
},
|
|
setBrightness(nextBrightness: number) {
|
|
this.brightness = nextBrightness
|
|
},
|
|
setSurfaceOpacity(nextSurfaceOpacity: number) {
|
|
this.surfaceOpacity = nextSurfaceOpacity
|
|
},
|
|
setStyle(nextStyle: NonNullable<ThemePreferences['style']>) {
|
|
this.style = nextStyle
|
|
},
|
|
setSurfacePalette(nextPalette: NonNullable<ThemePreferences['surfacePalette']>) {
|
|
this.surfacePalette = nextPalette
|
|
},
|
|
setGlowReach(reach: number) {
|
|
this.glowReach = reach
|
|
},
|
|
})
|
|
}
|
|
|
|
let themeStore = createThemeStore()
|
|
|
|
function mockJsonResponse(body: unknown, ok = true) {
|
|
return {
|
|
ok,
|
|
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
|
}
|
|
}
|
|
|
|
vi.mock('@/stores/theme', () => ({
|
|
useThemeStore: () => themeStore,
|
|
}))
|
|
|
|
vi.mock('@/features/auth/composables/useAuth', () => ({
|
|
useAuth: () => ({ user }),
|
|
}))
|
|
|
|
vi.mock('@/lib/api', () => ({
|
|
api: (...args: unknown[]) => apiMock(...args),
|
|
getAccessToken: () => 'test-access-token',
|
|
}))
|
|
|
|
vi.mock('vue-sonner', () => ({
|
|
toast: { error: toastError },
|
|
}))
|
|
|
|
describe('useThemeSync', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
user.value = { settings: { syncThemePreferences: false } }
|
|
themeStore = createThemeStore()
|
|
apiMock.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('loadFromServer applies valid fields from the server payload', async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({
|
|
settings: {
|
|
theme: 'light',
|
|
accent: 'rose',
|
|
radius: 'pill',
|
|
background: 'mesh',
|
|
brightness: 72,
|
|
},
|
|
}),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.theme).toBe('light')
|
|
expect(themeStore.accent).toBe('rose')
|
|
expect(themeStore.radius).toBe('pill')
|
|
expect(themeStore.background).toBe('mesh')
|
|
expect(themeStore.brightness).toBe(72)
|
|
})
|
|
|
|
it('loadFromServer applies surfaceOpacity within the supported range', async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, surfaceOpacity: 80 } }),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.surfaceOpacity).toBe(80)
|
|
})
|
|
|
|
it.each([79, 101, 92.5, 'full'])('loadFromServer ignores out-of-range surfaceOpacity %s', async (surfaceOpacity) => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, surfaceOpacity } }),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.surfaceOpacity).toBe(92)
|
|
})
|
|
|
|
it('loadFromServer keeps the local surfaceOpacity when the server predates the field', async () => {
|
|
themeStore.surfaceOpacity = 85
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'light', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35 } }),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.surfaceOpacity).toBe(85)
|
|
})
|
|
|
|
it('loadFromServer applies a synchronized system theme selection', async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'system', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35 } }),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.theme).toBe('system')
|
|
})
|
|
|
|
it('loadFromServer no-ops when server settings are null', async () => {
|
|
apiMock.mockResolvedValueOnce(mockJsonResponse({ settings: null }))
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.theme).toBe('dark')
|
|
expect(themeStore.accent).toBe('blue')
|
|
expect(themeStore.radius).toBe('rounded')
|
|
expect(themeStore.background).toBe('vinyl')
|
|
expect(themeStore.brightness).toBe(35)
|
|
})
|
|
|
|
it('loadFromServer swallows api errors', async () => {
|
|
apiMock.mockRejectedValueOnce(new Error('boom'))
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
|
|
await expect(loadFromServer()).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('loadFromServer sanitizes invalid values and keeps existing local ones', async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({
|
|
settings: {
|
|
theme: 'light',
|
|
accent: 'invalid',
|
|
radius: 'pill',
|
|
background: 'not-real',
|
|
brightness: 101,
|
|
},
|
|
}),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.theme).toBe('light')
|
|
expect(themeStore.accent).toBe('blue')
|
|
expect(themeStore.radius).toBe('pill')
|
|
expect(themeStore.background).toBe('vinyl')
|
|
expect(themeStore.brightness).toBe(35)
|
|
})
|
|
|
|
it('loadFromServer does not trigger a save request while applying server data', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'light', accent: 'rose', radius: 'pill', background: 'mesh', brightness: 70 } }),
|
|
)
|
|
|
|
const { initThemeSync, loadFromServer } = await import('../useThemeSync')
|
|
initThemeSync()
|
|
await loadFromServer()
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
|
|
expect(apiMock).toHaveBeenCalledTimes(1)
|
|
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/theme')
|
|
})
|
|
|
|
it('saveToServer calls PUT with the expected payload', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValueOnce(mockJsonResponse({}, true))
|
|
const prefs: ThemePreferences = {
|
|
theme: 'dark',
|
|
accent: 'blue',
|
|
radius: 'rounded',
|
|
background: 'vinyl',
|
|
brightness: 35,
|
|
}
|
|
|
|
const { saveToServer } = await import('../useThemeSync')
|
|
await saveToServer(prefs)
|
|
|
|
expect(apiMock).toHaveBeenCalledWith(
|
|
'/api/v1/user-preferences/theme',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify({ settings: prefs }),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('saveToServer shows a toast when the server rejects the request', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValueOnce(mockJsonResponse({}, false))
|
|
|
|
const { saveToServer } = await import('../useThemeSync')
|
|
await saveToServer({
|
|
theme: 'dark',
|
|
accent: 'blue',
|
|
radius: 'rounded',
|
|
background: 'vinyl',
|
|
brightness: 35,
|
|
})
|
|
|
|
expect(toastError).toHaveBeenCalledWith('Failed to save theme preferences')
|
|
})
|
|
|
|
it('initThemeSync is idempotent', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
|
|
|
const { initThemeSync } = await import('../useThemeSync')
|
|
initThemeSync()
|
|
initThemeSync()
|
|
|
|
themeStore.setAccent('rose')
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
|
|
expect(apiMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('watcher only saves when sync is enabled', async () => {
|
|
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
|
|
|
const { initThemeSync } = await import('../useThemeSync')
|
|
initThemeSync()
|
|
|
|
themeStore.setAccent('rose')
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
expect(apiMock).not.toHaveBeenCalled()
|
|
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
themeStore.setAccent('green')
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
|
|
expect(apiMock).toHaveBeenCalledWith(
|
|
'/api/v1/user-preferences/theme',
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
settings: {
|
|
theme: 'dark',
|
|
accent: 'green',
|
|
radius: 'rounded',
|
|
background: 'vinyl',
|
|
brightness: 35,
|
|
surfaceOpacity: 92,
|
|
style: 'bookorbit',
|
|
surfacePalette: 'ash',
|
|
glowReach: 100,
|
|
},
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('saves a style or surface palette change like any other appearance setting', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
|
|
|
const { initThemeSync } = await import('../useThemeSync')
|
|
initThemeSync()
|
|
|
|
themeStore.setStyle('nostalgia')
|
|
themeStore.setSurfacePalette('sepia')
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
|
|
expect(apiMock).toHaveBeenCalledTimes(1)
|
|
const body = JSON.parse((apiMock.mock.calls[0]![1] as { body: string }).body) as { settings: ThemePreferences }
|
|
expect(body.settings).toMatchObject({ style: 'nostalgia', surfacePalette: 'sepia' })
|
|
})
|
|
|
|
it('loadFromServer applies a synchronized style and surface palette', async () => {
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({
|
|
settings: {
|
|
theme: 'dark',
|
|
accent: 'blue',
|
|
radius: 'rounded',
|
|
background: 'vinyl',
|
|
brightness: 35,
|
|
style: 'nostalgia',
|
|
surfacePalette: 'olive',
|
|
},
|
|
}),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.style).toBe('nostalgia')
|
|
expect(themeStore.surfacePalette).toBe('olive')
|
|
})
|
|
|
|
it('loadFromServer applies a synchronized glow reach and ignores one out of range', async () => {
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, glowReach: 40 } }),
|
|
)
|
|
await loadFromServer()
|
|
expect(themeStore.glowReach).toBe(40)
|
|
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, glowReach: 900 } }),
|
|
)
|
|
await loadFromServer()
|
|
expect(themeStore.glowReach).toBe(40)
|
|
})
|
|
|
|
it('loadFromServer keeps the local style when the server value is unknown or absent', async () => {
|
|
themeStore.style = 'nostalgia'
|
|
themeStore.surfacePalette = 'pine'
|
|
apiMock.mockResolvedValueOnce(
|
|
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, style: 'retro' } }),
|
|
)
|
|
|
|
const { loadFromServer } = await import('../useThemeSync')
|
|
await loadFromServer()
|
|
|
|
expect(themeStore.style).toBe('nostalgia')
|
|
expect(themeStore.surfacePalette).toBe('pine')
|
|
})
|
|
|
|
it('cancelPendingThemeSync prevents a scheduled save from firing', async () => {
|
|
user.value = { settings: { syncThemePreferences: true } }
|
|
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
|
|
|
const { initThemeSync, cancelPendingThemeSync } = await import('../useThemeSync')
|
|
initThemeSync()
|
|
|
|
themeStore.setAccent('rose')
|
|
cancelPendingThemeSync()
|
|
await vi.advanceTimersByTimeAsync(1600)
|
|
|
|
expect(apiMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|