606 lines
21 KiB
TypeScript
606 lines
21 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// jsdom reports an EMPTY navigator.platform and a userAgent of "darwin", so
|
|
// GLASS_SUPPORTED resolves false and every glass assertion in this file
|
|
// silently turns into a no-op — on a Mac too. Pin a real macOS platform
|
|
// BEFORE the store module is evaluated (hoisted above the imports), so the
|
|
// glass path is the one actually under test.
|
|
vi.hoisted(() => {
|
|
Object.defineProperty(globalThis.navigator, 'platform', { configurable: true, value: 'MacIntel' })
|
|
})
|
|
|
|
import { DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE } from '@hermes/shared/translucency'
|
|
|
|
import { onPersistenceEvent, type PersistenceEvent } from '@/lib/storage'
|
|
|
|
import {
|
|
$translucency,
|
|
$translucencyBook,
|
|
$translucencyPeek,
|
|
beginTranslucencyPeek,
|
|
defaultTranslucencyValues,
|
|
endTranslucencyPeek,
|
|
GLASS_SUPPORTED,
|
|
isChatWindow,
|
|
resetTranslucencyPeek,
|
|
setAppearance,
|
|
setTranslucency,
|
|
setTranslucencyFade,
|
|
setTranslucencyMaterial,
|
|
setTranslucencyMode,
|
|
setTranslucencyScope,
|
|
TRANSLUCENCY_MAX,
|
|
TRANSLUCENCY_MIN,
|
|
TRANSLUCENCY_STEP
|
|
} from './translucency'
|
|
|
|
const KEY = 'hermes.desktop.translucency.v2'
|
|
const LEGACY_KEY = 'hermes.desktop.translucency.v1'
|
|
|
|
// The book is per-appearance; the tests below drive one appearance at a time.
|
|
// Dark is the store's initial appearance, so it is also the reset target.
|
|
// This suite pins navigator.platform to a Mac before import, so the Mac table
|
|
// is the one in play — `GLASS_IS_WINDOWS` resolves false throughout.
|
|
const DARK = defaultTranslucencyValues('dark', false)
|
|
const LIGHT = defaultTranslucencyValues('light', false)
|
|
|
|
const glassAttr = () => document.documentElement.hasAttribute('data-hermes-glass')
|
|
const clearAttr = () => document.documentElement.hasAttribute('data-hermes-clear')
|
|
const keep = () => document.documentElement.style.getPropertyValue('--translucency-glass-keep')
|
|
|
|
// Snapshotted at import time: every describe below mutates the store, so the
|
|
// default can only be observed here.
|
|
const initialTranslucency = $translucency.get()
|
|
|
|
describe('window translucency lever', () => {
|
|
beforeEach(() => {
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
})
|
|
|
|
it('steps in single percent so the readable low end is reachable', () => {
|
|
expect(TRANSLUCENCY_STEP).toBe(1)
|
|
expect(TRANSLUCENCY_MIN).toBe(0)
|
|
expect(TRANSLUCENCY_MAX).toBe(100)
|
|
})
|
|
|
|
// NB: this asserts the module's INITIAL value, so it deliberately reads the
|
|
// atom before the beforeEach above can touch it — the previous version of
|
|
// this test ran after the reset and so proved nothing about the default.
|
|
it('starts on the dark appearance defaults, glass-backed on macOS', () => {
|
|
expect(initialTranslucency).toEqual({ ...DARK, mode: GLASS_SUPPORTED ? 'glass' : 'clear' })
|
|
})
|
|
|
|
it('accepts every step the slider can emit', () => {
|
|
for (let intensity = TRANSLUCENCY_MIN; intensity <= TRANSLUCENCY_MAX; intensity += TRANSLUCENCY_STEP) {
|
|
setTranslucency(intensity)
|
|
expect($translucency.get().intensity).toBe(intensity)
|
|
}
|
|
})
|
|
|
|
it('clamps out-of-range input and rounds fractions', () => {
|
|
setTranslucency(-40)
|
|
expect($translucency.get().intensity).toBe(TRANSLUCENCY_MIN)
|
|
|
|
setTranslucency(240)
|
|
expect($translucency.get().intensity).toBe(TRANSLUCENCY_MAX)
|
|
|
|
setTranslucency(35.6)
|
|
expect($translucency.get().intensity).toBe(36)
|
|
})
|
|
|
|
it('changing one half of the state leaves the other alone', () => {
|
|
setTranslucency(42)
|
|
setTranslucencyMode('glass')
|
|
expect($translucency.get().intensity).toBe(42)
|
|
|
|
setTranslucency(43)
|
|
expect($translucency.get().mode).toBe(GLASS_SUPPORTED ? 'glass' : 'clear')
|
|
})
|
|
|
|
// Persistence is debounced: the slider emits ~100 updates per drag and
|
|
// localStorage.setItem is synchronous, so writing per tick janks the drag.
|
|
// Nothing reads the stored value until a cold launch.
|
|
it('persists intensity and mode together under one stable key, once the drag settles', () => {
|
|
vi.useFakeTimers()
|
|
const writes: PersistenceEvent[] = []
|
|
|
|
const stop = onPersistenceEvent(event => {
|
|
if (event.op === 'write') {
|
|
writes.push(event)
|
|
}
|
|
})
|
|
|
|
try {
|
|
// A drag: every intermediate value paints, none of them hit storage.
|
|
for (let intensity = 18; intensity <= 23; intensity += 1) {
|
|
setTranslucency(intensity)
|
|
}
|
|
|
|
expect(writes).toHaveLength(0)
|
|
|
|
vi.advanceTimersByTime(200)
|
|
|
|
// One write, carrying the value the hand landed on — recorded against
|
|
// the appearance being painted, not against the shared base.
|
|
expect(writes).toHaveLength(1)
|
|
expect(writes.at(-1)).toEqual({
|
|
key: KEY,
|
|
op: 'write',
|
|
value: JSON.stringify({ ...$translucencyBook.get(), dark: { intensity: 23 } })
|
|
})
|
|
} finally {
|
|
stop()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
// Flipped from an earlier version that asserted the bridge was debounced:
|
|
// in clear mode the native window fade IS the effect, and main can only
|
|
// move it when told — a debounced send froze the fade mid-drag. Main diffs
|
|
// and debounces its own disk write, so per-tick sends are cheap.
|
|
it('mirrors every tick to the desktop bridge so the clear-mode fade tracks the drag', () => {
|
|
vi.useFakeTimers()
|
|
const calls: unknown[] = []
|
|
window.hermesDesktop = { setTranslucency: (payload: unknown) => calls.push(payload) } as never
|
|
|
|
try {
|
|
for (let intensity = 36; intensity <= 40; intensity += 1) {
|
|
setTranslucency(intensity)
|
|
}
|
|
|
|
expect(calls).toHaveLength(5)
|
|
expect(calls.at(-1)).toEqual({ ...DARK, intensity: 40, mode: 'clear' })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
// The page paint is the one thing that must NOT wait for the debounce —
|
|
// the field has to track the hand.
|
|
it('paints every tick even though it persists once', () => {
|
|
vi.useFakeTimers()
|
|
|
|
try {
|
|
setTranslucencyMode('glass')
|
|
setTranslucency(30)
|
|
expect(keep()).toBe('70%')
|
|
|
|
setTranslucency(80)
|
|
expect(keep()).toBe('20%')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('glass mode', () => {
|
|
// Guard the guard: if this env ever stops reporting mac, every assertion in
|
|
// this block quietly turns into a no-op instead of failing.
|
|
it('runs its glass assertions for real in this environment', () => {
|
|
expect(GLASS_SUPPORTED).toBe(true)
|
|
})
|
|
|
|
beforeEach(() => {
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
})
|
|
|
|
it('rejects glass when the platform cannot back it', () => {
|
|
setTranslucency(50)
|
|
setTranslucencyMode('glass')
|
|
|
|
if (GLASS_SUPPORTED) {
|
|
expect($translucency.get().mode).toBe('glass')
|
|
expect(glassAttr()).toBe(true)
|
|
expect(keep()).toBe('50%')
|
|
} else {
|
|
expect($translucency.get().mode).toBe('clear')
|
|
expect(glassAttr()).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('drops the glass attribute at zero intensity or back on clear', () => {
|
|
setTranslucency(50)
|
|
setTranslucencyMode('glass')
|
|
|
|
setTranslucency(0)
|
|
expect(glassAttr()).toBe(false)
|
|
expect(keep()).toBe('')
|
|
|
|
setTranslucency(50)
|
|
setTranslucencyMode('clear')
|
|
expect(glassAttr()).toBe(false)
|
|
})
|
|
|
|
// Clear and glass are mutually exclusive page states: clear strengthens the
|
|
// overlay scrim, glass thins the field. Both at once would fight.
|
|
it('marks clear mode separately, and never both at once', () => {
|
|
setTranslucency(50)
|
|
expect(clearAttr()).toBe(true)
|
|
expect(glassAttr()).toBe(false)
|
|
|
|
setTranslucencyMode('glass')
|
|
|
|
if (GLASS_SUPPORTED) {
|
|
expect(clearAttr()).toBe(false)
|
|
expect(glassAttr()).toBe(true)
|
|
}
|
|
|
|
setTranslucency(0)
|
|
expect(clearAttr()).toBe(false)
|
|
expect(glassAttr()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('frost and area', () => {
|
|
beforeEach(() => {
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
setTranslucencyMaterial(DEFAULT_GLASS_MATERIAL)
|
|
setTranslucencyScope(DEFAULT_GLASS_SCOPE)
|
|
})
|
|
|
|
it('carries the chosen material without disturbing the rest of the state', () => {
|
|
setTranslucency(40)
|
|
setTranslucencyMaterial('header')
|
|
|
|
expect($translucency.get().material).toBe('header')
|
|
expect($translucency.get().intensity).toBe(40)
|
|
})
|
|
|
|
it('rejects a material outside the shipped ladder', () => {
|
|
setTranslucencyMaterial('sidebar' as never)
|
|
expect($translucency.get().material).toBe(DEFAULT_GLASS_MATERIAL)
|
|
})
|
|
|
|
// The sidebar scope is the Finder shape, and styles.css keys its split-paint
|
|
// gradient off this attribute — so the attribute IS the contract. It must
|
|
// also be REMOVED whenever glass goes away: a stale scope left on <html>
|
|
// keeps the split-paint gradient selector live over a non-glass field.
|
|
it('publishes the area as an attribute only while glass is on', () => {
|
|
setTranslucency(50)
|
|
setTranslucencyScope('sidebar')
|
|
|
|
if (!GLASS_SUPPORTED) {
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-scope')).toBe(false)
|
|
|
|
return
|
|
}
|
|
|
|
setTranslucencyMode('glass')
|
|
expect(document.documentElement.getAttribute('data-hermes-glass-scope')).toBe('sidebar')
|
|
|
|
setTranslucencyScope('window')
|
|
expect(document.documentElement.getAttribute('data-hermes-glass-scope')).toBe('window')
|
|
|
|
// Each of the three ways glass can end has to clear it, independently.
|
|
setTranslucency(0)
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-scope')).toBe(false)
|
|
|
|
setTranslucency(50)
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-scope')).toBe(true)
|
|
|
|
setTranslucencyMode('clear')
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-scope')).toBe(false)
|
|
})
|
|
})
|
|
|
|
// A held slider drag and a timed pulse from a picker click can overlap, which
|
|
// is why the peek counts rather than toggling: the drag must not be cancelled
|
|
// by a pulse expiring underneath it.
|
|
describe('translucency peek', () => {
|
|
it('stays open until every overlapping hold has ended', () => {
|
|
beginTranslucencyPeek()
|
|
beginTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(true)
|
|
|
|
endTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(true)
|
|
|
|
endTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(false)
|
|
})
|
|
|
|
it('never goes negative, so a stray release cannot wedge the next peek open', () => {
|
|
endTranslucencyPeek()
|
|
endTranslucencyPeek()
|
|
expect($translucencyPeek.get()).toBe(0)
|
|
|
|
beginTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(true)
|
|
endTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('peek reset', () => {
|
|
// Escape mid-drag unmounts the slider before its pointerup ever fires; the
|
|
// settings surface calls reset on unmount so the counter can't stay wedged
|
|
// and ghost the next overlay at 8% opacity.
|
|
it('drops every outstanding hold at once', () => {
|
|
beginTranslucencyPeek()
|
|
beginTranslucencyPeek()
|
|
beginTranslucencyPeek()
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(true)
|
|
|
|
resetTranslucencyPeek()
|
|
expect($translucencyPeek.get()).toBe(0)
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(false)
|
|
|
|
// A pulse timer expiring after the reset must not push the counter negative
|
|
// or resurrect the attribute.
|
|
endTranslucencyPeek()
|
|
expect($translucencyPeek.get()).toBe(0)
|
|
expect(document.documentElement.hasAttribute('data-hermes-translucency-peek')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('cross-window sync', () => {
|
|
afterEach(() => {
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
})
|
|
|
|
// Under glass main touches nothing native on an intensity change, so a
|
|
// sibling window's storage event is the ONLY way this window hears about it.
|
|
it("adopts a sibling window's persisted state", () => {
|
|
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'clear', base: {}, light: {}, dark: { intensity: 77 } }))
|
|
|
|
window.dispatchEvent(new StorageEvent('storage', { key: KEY, newValue: 'x' }))
|
|
|
|
expect($translucency.get().intensity).toBe(77)
|
|
})
|
|
|
|
it('ignores storage events for other keys', () => {
|
|
setTranslucency(12)
|
|
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'clear', base: {}, light: {}, dark: { intensity: 99 } }))
|
|
|
|
window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.zoom.v1', newValue: 'x' }))
|
|
|
|
expect($translucency.get().intensity).toBe(12)
|
|
})
|
|
})
|
|
|
|
// The store must actually CONSULT isChatWindow, not merely export it: glass
|
|
// rewrites page-level surfaces, and the HUD / pet overlay / quick entry own
|
|
// their own backgrounds. Driving window.location.search proves the wiring.
|
|
describe('glass is confined to chat windows', () => {
|
|
const setSearch = (search: string) => {
|
|
Object.defineProperty(window, 'location', {
|
|
configurable: true,
|
|
value: { ...window.location, search }
|
|
})
|
|
}
|
|
|
|
const originalLocation = window.location
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
|
|
setTranslucencyMode('clear')
|
|
setTranslucency(TRANSLUCENCY_MIN)
|
|
})
|
|
|
|
it('does not thin surfaces in a special-purpose window', () => {
|
|
setSearch('?win=hud')
|
|
setTranslucency(60)
|
|
setTranslucencyMode('glass')
|
|
|
|
// The mode is still the user's choice — only the page rewrite is withheld.
|
|
expect($translucency.get().mode).toBe('glass')
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass')).toBe(false)
|
|
})
|
|
|
|
// The HUD paints its band from the app's field mix, so it needs the setting
|
|
// and the tint number even though its surfaces must not be rewritten. The
|
|
// two flags are what keep those separable: keying the band off
|
|
// `data-hermes-glass` would silently never match.
|
|
it('still publishes the live setting and the tint to a special-purpose window', () => {
|
|
setSearch('?win=hud')
|
|
setTranslucency(60)
|
|
setTranslucencyMode('glass')
|
|
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(true)
|
|
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('40%')
|
|
})
|
|
|
|
it('withdraws both flags when glass is switched off', () => {
|
|
setSearch('?win=hud')
|
|
setTranslucency(60)
|
|
setTranslucencyMode('glass')
|
|
setTranslucencyMode('clear')
|
|
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass-on')).toBe(false)
|
|
expect(document.documentElement.style.getPropertyValue('--translucency-glass-keep')).toBe('')
|
|
})
|
|
|
|
it('thins them in the primary window', () => {
|
|
setSearch('')
|
|
setTranslucency(60)
|
|
setTranslucencyMode('glass')
|
|
|
|
expect(document.documentElement.hasAttribute('data-hermes-glass')).toBe(true)
|
|
})
|
|
})
|
|
|
|
// Glass rewrites page surfaces, which is only correct in a real chat window.
|
|
// The HUD, pet overlay, quick entry and wake indicator are transparent
|
|
// special-purpose windows that own their own backgrounds.
|
|
describe('isChatWindow', () => {
|
|
it('accepts the primary window and secondary session windows', () => {
|
|
expect(isChatWindow('')).toBe(true)
|
|
expect(isChatWindow('?theme=dark')).toBe(true)
|
|
expect(isChatWindow('?win=secondary')).toBe(true)
|
|
expect(isChatWindow('?win=secondary&session=abc')).toBe(true)
|
|
})
|
|
|
|
it('rejects every special-purpose window kind', () => {
|
|
for (const win of ['hud', 'pet', 'quick-entry', 'wake', 'anything-new']) {
|
|
expect(isChatWindow(`?win=${win}`), win).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
// A tint that reads as a whisper over a dark palette is a milky sheet over a
|
|
// light one, so the same lever has to mean a different amount in each
|
|
// appearance. These are the contract for that split.
|
|
describe('per-appearance settings', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear()
|
|
$translucencyBook.set({ mode: 'glass', base: {}, light: {}, dark: {} })
|
|
setAppearance('dark')
|
|
})
|
|
|
|
afterEach(() => setAppearance('dark'))
|
|
|
|
it('ships each appearance its own defaults', () => {
|
|
expect($translucency.get()).toEqual({ ...DARK, mode: 'glass' })
|
|
|
|
setAppearance('light')
|
|
expect($translucency.get()).toEqual({ ...LIGHT, mode: 'glass' })
|
|
})
|
|
|
|
it('ships glass ON, so the feature is visible without being found first', () => {
|
|
expect($translucency.get().mode).toBe('glass')
|
|
expect($translucency.get().intensity).toBeGreaterThan(0)
|
|
|
|
setAppearance('light')
|
|
expect($translucency.get().intensity).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('scopes an edit to the appearance it was made in', () => {
|
|
setAppearance('light')
|
|
setTranslucency(90)
|
|
setTranslucencyFade(7)
|
|
|
|
expect($translucency.get().intensity).toBe(90)
|
|
expect($translucency.get().fade).toBe(7)
|
|
|
|
// Dark never heard about it and keeps its own default.
|
|
setAppearance('dark')
|
|
expect($translucency.get().intensity).toBe(DARK.intensity)
|
|
expect($translucency.get().fade).toBe(DARK.fade)
|
|
|
|
// ...and light still holds the edit on the way back.
|
|
setAppearance('light')
|
|
expect($translucency.get().intensity).toBe(90)
|
|
})
|
|
|
|
it('carries an unset key over from the shared base', () => {
|
|
$translucencyBook.set({ mode: 'glass', base: { intensity: 44, scope: 'sidebar' }, light: {}, dark: {} })
|
|
|
|
// Neither appearance has an opinion, so both inherit base.
|
|
expect($translucency.get().intensity).toBe(44)
|
|
expect($translucency.get().scope).toBe('sidebar')
|
|
|
|
setAppearance('light')
|
|
expect($translucency.get().intensity).toBe(44)
|
|
|
|
// An edit overrides only the key it touches; the rest keeps inheriting.
|
|
setTranslucency(12)
|
|
expect($translucency.get().intensity).toBe(12)
|
|
expect($translucency.get().scope).toBe('sidebar')
|
|
|
|
setAppearance('dark')
|
|
expect($translucency.get().intensity).toBe(44)
|
|
})
|
|
|
|
// Clear vs glass is a choice about the window, not about the palette —
|
|
// splitting it would mean flipping appearance silently changed the mode.
|
|
it('keeps the mode global across appearances', () => {
|
|
setTranslucencyMode('clear')
|
|
expect($translucency.get().mode).toBe('clear')
|
|
|
|
setAppearance('light')
|
|
expect($translucency.get().mode).toBe('clear')
|
|
})
|
|
|
|
// Switching appearance changes what is painted but not what is stored, so
|
|
// it must not schedule a write.
|
|
it('does not persist on an appearance switch alone', () => {
|
|
vi.useFakeTimers()
|
|
const writes: PersistenceEvent[] = []
|
|
|
|
const stop = onPersistenceEvent(event => {
|
|
if (event.op === 'write') {
|
|
writes.push(event)
|
|
}
|
|
})
|
|
|
|
try {
|
|
setAppearance('light')
|
|
setAppearance('dark')
|
|
vi.advanceTimersByTime(400)
|
|
|
|
expect(writes).toHaveLength(0)
|
|
} finally {
|
|
stop()
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
// A window someone already tuned must survive the upgrade unchanged — the new
|
|
// per-appearance defaults may only fill in where nothing was ever set.
|
|
describe('v1 → v2 migration', () => {
|
|
afterEach(() => {
|
|
window.localStorage.clear()
|
|
setAppearance('dark')
|
|
})
|
|
|
|
it('lands a tuned v1 state in base, so both appearances inherit it', async () => {
|
|
window.localStorage.clear()
|
|
window.localStorage.setItem(
|
|
LEGACY_KEY,
|
|
JSON.stringify({ intensity: 31, fade: 4, mode: 'glass', material: 'popover', scope: 'sidebar' })
|
|
)
|
|
|
|
vi.resetModules()
|
|
const fresh = await import('./translucency')
|
|
|
|
expect(fresh.$translucencyBook.get()).toEqual({
|
|
mode: 'glass',
|
|
base: { intensity: 31, fade: 4, material: 'popover', scope: 'sidebar' },
|
|
light: {},
|
|
dark: {}
|
|
})
|
|
|
|
fresh.setAppearance('light')
|
|
expect(fresh.$translucency.get()).toEqual({
|
|
intensity: 31,
|
|
fade: 4,
|
|
mode: 'glass',
|
|
material: 'popover',
|
|
scope: 'sidebar'
|
|
})
|
|
})
|
|
|
|
// The legacy rule: a non-zero v1 intensity with no mode was rendering as
|
|
// clear all along and has to keep doing so.
|
|
it('keeps a legacy mode-less state on clear', async () => {
|
|
window.localStorage.clear()
|
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ intensity: 25 }))
|
|
|
|
vi.resetModules()
|
|
const fresh = await import('./translucency')
|
|
|
|
expect(fresh.$translucency.get().mode).toBe('clear')
|
|
expect(fresh.$translucency.get().intensity).toBe(25)
|
|
})
|
|
|
|
it('prefers a v2 book over a stale v1 state', async () => {
|
|
window.localStorage.clear()
|
|
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ intensity: 25, mode: 'clear' }))
|
|
window.localStorage.setItem(KEY, JSON.stringify({ mode: 'glass', base: {}, light: {}, dark: { intensity: 8 } }))
|
|
|
|
vi.resetModules()
|
|
const fresh = await import('./translucency')
|
|
|
|
expect(fresh.$translucency.get().mode).toBe('glass')
|
|
expect(fresh.$translucency.get().intensity).toBe(8)
|
|
})
|
|
})
|