Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/subsystems/tasks.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/tasks/tasks-local/src/index.ts # packages/tasks/tasks/README.i18n.yaml # packages/tasks/tasks/README.md # packages/tasks/tasks/README.zh.md # packages/tasks/tasks/src/index.ts
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
/** locale apply wiring: service + dictionaries provision, declaration-aware
|
||||
* Language row registration, snapshot projection into the row store, and
|
||||
* recovery after an HMR collapse of the declaring entry. */
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import {
|
||||
apply, inject, SETTINGS_NS,
|
||||
} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
|
||||
import { LanguageRow } from '../src/client/LanguageRow.tsx'
|
||||
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
|
||||
|
||||
@@ -14,7 +17,36 @@ const SLOT = 'settings.general.item'
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService }
|
||||
let preference: string | undefined
|
||||
let revision = 0
|
||||
const namespace = () => ({
|
||||
ns: LOCALE_SETTINGS_NAMESPACE,
|
||||
schema: LocaleSettingsSchema.toJSON(),
|
||||
value: preference === undefined ? {} : { preference },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision,
|
||||
})
|
||||
const describe = vi.fn(async () => ({
|
||||
rpcId: 'locale-describe' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
|
||||
},
|
||||
}))
|
||||
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
|
||||
preference = request.ops[0]!.value
|
||||
revision += 1
|
||||
return {
|
||||
rpcId: 'locale-mutate' as never,
|
||||
result: { ok: true as const, value: namespace() },
|
||||
}
|
||||
})
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
|
||||
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
@@ -47,7 +79,7 @@ describe('locale apply', () => {
|
||||
})
|
||||
|
||||
it('declares the slot service', () => {
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
})
|
||||
|
||||
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
|
||||
@@ -91,6 +123,23 @@ describe('locale apply', () => {
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
|
||||
})
|
||||
|
||||
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('en')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const locale = b.ctx.get('locale') as LocaleService
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
b.setHostPreference(undefined)
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
|
||||
b.setHostPreference('en')
|
||||
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
|
||||
expect(b.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
30
packages/client/locale/tests/host.spec.ts
Normal file
30
packages/client/locale/tests/host.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
LOCALE_SETTINGS_NAMESPACE, apply,
|
||||
} from '@deepseek-ai/dsh-client-locale'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('locale host', () => {
|
||||
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE)
|
||||
expect(ctx.settings.get(ns)).toEqual({})
|
||||
await ctx.settings.update(ns, { preference: 'en' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
|
||||
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
|
||||
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
|
||||
@@ -14,16 +14,16 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', () => {
|
||||
nodeApply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
it('node-half apply tolerates a Host without settings', () => {
|
||||
nodeApply(new Context())
|
||||
})
|
||||
|
||||
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
|
||||
// The feature registers its own Language settings row, hence the slots edge.
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(inject).toEqual(['slots', 'connection'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
const locale = ctx.get('locale')
|
||||
expect(locale).toBeInstanceOf(LocaleService)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
|
||||
const make = (host?: StubSettingsScope<LocaleSettings>): {
|
||||
ctx: Context
|
||||
svc: LocaleService
|
||||
events: LocaleSnapshot[]
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: LocaleSnapshot[] = []
|
||||
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, svc: new LocaleService(ctx), events }
|
||||
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +29,6 @@ const stubLanguages = (...tags: string[]): void => {
|
||||
|
||||
describe('LocaleService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// A Chinese browser is the baseline these specs assert their zh state on.
|
||||
stubLanguages('zh-CN')
|
||||
})
|
||||
@@ -132,16 +136,25 @@ describe('LocaleService', () => {
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
|
||||
expect(host.set).toHaveBeenCalledWith('preference', 'en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toBe(svc.getLocale())
|
||||
expect(events[0]!.revision).toBe(1)
|
||||
svc.setLocale('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setLocale without a host scope stays process-local', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on unknown locale ids', () => {
|
||||
@@ -149,14 +162,37 @@ describe('LocaleService', () => {
|
||||
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('restores a persisted locale over the browser language, and garbage reads as no preference', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'en')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
localStorage.setItem(STORAGE_KEY, 'fr')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
it('adopts a Host preference over the browser language without writing it back', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc, events } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(events).toHaveLength(1)
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
host.publish({ value: { preference: 'en' }, revision: 2 })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => {
|
||||
it('an absent Host preference returns to the browser-derived locale', () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
const { svc } = make(host)
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
host.publish({ value: {}, revision: 2 })
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
|
||||
const host = stubSettingsScope<LocaleSettings>()
|
||||
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
|
||||
const { ctx, svc } = make(host)
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
await ctx.fiber.dispose()
|
||||
expect(host.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
|
||||
stubLanguages('en-GB', 'zh-CN')
|
||||
expect(make().svc.getLocale().active).toBe('en')
|
||||
stubLanguages('zh-Hant-TW')
|
||||
@@ -176,8 +212,7 @@ describe('LocaleService', () => {
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
// Node exposes its own global navigator; without a window it must not
|
||||
// reach the resolution at all.
|
||||
@@ -188,12 +223,11 @@ describe('LocaleService', () => {
|
||||
expect(svc.getLocale().active).toBe('en')
|
||||
})
|
||||
|
||||
it('keeps the browser language out of the way once a preference exists', () => {
|
||||
it('lets an explicit in-process preference replace the browser-derived value', () => {
|
||||
stubLanguages('en-US')
|
||||
const { svc } = make()
|
||||
svc.setLocale('zh')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('zh')
|
||||
expect(make().svc.getLocale().active).toBe('zh')
|
||||
expect(svc.getLocale().active).toBe('zh')
|
||||
})
|
||||
|
||||
it('exposes the two shipped locales with self-described labels', () => {
|
||||
|
||||
Reference in New Issue
Block a user