fix(client): lint conformance for the preference services
Replace optional chains on always-present globals with the repo's typeof guards (store.ts precedent), drop the non-null assertion by failing loud on an impossible registry miss, and fix two arrow-parens slips; cover the no-localStorage boot path in both service suites.
This commit is contained in:
@@ -161,8 +161,10 @@ export class LocaleService {
|
|||||||
|
|
||||||
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
||||||
function restorePreference(): LocaleId {
|
function restorePreference(): LocaleId {
|
||||||
|
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||||
|
if (typeof localStorage === 'undefined') return FALLBACK_LOCALE
|
||||||
try {
|
try {
|
||||||
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored === 'zh' || stored === 'en') return stored
|
if (stored === 'zh' || stored === 'en') return stored
|
||||||
} catch {
|
} catch {
|
||||||
// Storage access can throw (privacy mode); the default below covers it.
|
// Storage access can throw (privacy mode); the default below covers it.
|
||||||
@@ -172,8 +174,9 @@ function restorePreference(): LocaleId {
|
|||||||
|
|
||||||
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
|
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
|
||||||
function persistPreference(id: LocaleId): void {
|
function persistPreference(id: LocaleId): void {
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
try {
|
try {
|
||||||
globalThis.localStorage?.setItem(STORAGE_KEY, id)
|
localStorage.setItem(STORAGE_KEY, id)
|
||||||
} catch {
|
} catch {
|
||||||
// Storage access can throw (privacy mode / quota); the preference simply
|
// Storage access can throw (privacy mode / quota); the preference simply
|
||||||
// does not survive the session.
|
// does not survive the session.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||||
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
||||||
@@ -80,6 +80,18 @@ describe('LocaleService', () => {
|
|||||||
expect(make().svc.getLocale().active).toBe('zh')
|
expect(make().svc.getLocale().active).toBe('zh')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
|
||||||
|
vi.stubGlobal('localStorage', undefined)
|
||||||
|
try {
|
||||||
|
const { svc } = make()
|
||||||
|
expect(svc.getLocale().active).toBe('zh')
|
||||||
|
svc.setLocale('en')
|
||||||
|
expect(svc.getLocale().active).toBe('en')
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes the two shipped locales with self-described labels', () => {
|
it('exposes the two shipped locales with self-described labels', () => {
|
||||||
const { svc } = make()
|
const { svc } = make()
|
||||||
expect(svc.getLocale().locales).toEqual([
|
expect(svc.getLocale().locales).toEqual([
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const presenter = new ThemePresenter()
|
const presenter = new ThemePresenter()
|
||||||
presenter.apply(ctx.theme.getTheme())
|
presenter.apply(ctx.theme.getTheme())
|
||||||
const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) })
|
const off = ctx.on('theme/change', (snapshot) => { presenter.apply(snapshot) })
|
||||||
return () => {
|
return () => {
|
||||||
off()
|
off()
|
||||||
presenter.dispose()
|
presenter.dispose()
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
|
return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1))
|
||||||
},
|
},
|
||||||
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
||||||
subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener),
|
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
|
||||||
sections: () => ctx.slots.entries('settings.section')
|
sections: () => ctx.slots.entries('settings.section')
|
||||||
.map(e => ({
|
.map(e => ({
|
||||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export class ThemeService {
|
|||||||
constructor(ctx: Context) {
|
constructor(ctx: Context) {
|
||||||
this.ctx = ctx
|
this.ctx = ctx
|
||||||
this.preference = restorePreference()
|
this.preference = restorePreference()
|
||||||
this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
|
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
|
||||||
|
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
|
||||||
this.snapshot = this.buildSnapshot()
|
this.snapshot = this.buildSnapshot()
|
||||||
if (this.media !== undefined) {
|
if (this.media !== undefined) {
|
||||||
const media = this.media
|
const media = this.media
|
||||||
@@ -157,8 +158,9 @@ export class ThemeService {
|
|||||||
: this.preference
|
: this.preference
|
||||||
// Both built-ins always exist; a registered preference id resolves or has
|
// Both built-ins always exist; a registered preference id resolves or has
|
||||||
// been reset by its disposer, so the lookup cannot miss.
|
// been reset by its disposer, so the lookup cannot miss.
|
||||||
/* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */
|
const active = this.themes.find(t => t.id === resolvedId)
|
||||||
const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]!
|
/* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */
|
||||||
|
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
preference: this.preference,
|
preference: this.preference,
|
||||||
active,
|
active,
|
||||||
@@ -176,8 +178,10 @@ export class ThemeService {
|
|||||||
|
|
||||||
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
|
/** Read the persisted preference; unknown or unreadable values fall back to the default. */
|
||||||
function restorePreference(): ThemePreference {
|
function restorePreference(): ThemePreference {
|
||||||
|
// Non-browser runs (node e2e booting the client tree) have no localStorage.
|
||||||
|
if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE
|
||||||
try {
|
try {
|
||||||
const stored = globalThis.localStorage?.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
|
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
|
||||||
} catch {
|
} catch {
|
||||||
// Storage access can throw (privacy mode); the default below covers it.
|
// Storage access can throw (privacy mode); the default below covers it.
|
||||||
@@ -187,8 +191,9 @@ function restorePreference(): ThemePreference {
|
|||||||
|
|
||||||
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
|
/** Persist the preference; storage failures are non-fatal (preference resets next boot). */
|
||||||
function persistPreference(preference: ThemePreference): void {
|
function persistPreference(preference: ThemePreference): void {
|
||||||
|
if (typeof localStorage === 'undefined') return
|
||||||
try {
|
try {
|
||||||
globalThis.localStorage?.setItem(STORAGE_KEY, preference)
|
localStorage.setItem(STORAGE_KEY, preference)
|
||||||
} catch {
|
} catch {
|
||||||
// Storage access can throw (privacy mode / quota); the preference simply
|
// Storage access can throw (privacy mode / quota); the preference simply
|
||||||
// does not survive the session.
|
// does not survive the session.
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ describe('ThemeService', () => {
|
|||||||
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
|
||||||
|
vi.stubGlobal('localStorage', undefined)
|
||||||
|
try {
|
||||||
|
const { theme } = make()
|
||||||
|
expect(theme.getTheme().preference).toBe('system')
|
||||||
|
theme.setTheme('dark')
|
||||||
|
expect(theme.getTheme().preference).toBe('dark')
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
|
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {
|
||||||
type Listener = () => void
|
type Listener = () => void
|
||||||
const stubMedia = (initialMatches: boolean) => {
|
const stubMedia = (initialMatches: boolean) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user