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:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604
README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;未持久化偏好时,全新浏览器以 `navigator` 请求的语言开场——按主子标签匹配,若其请求的语言本应用都不提供则为 `zh`;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
locale 插件:LocaleService——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
## 模型体验

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
"version": "0.0.1",
"private": true,
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/client/locale"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -22,20 +29,24 @@
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
}
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -44,9 +55,13 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
},
"files": [
"lib/index.js",
"lib/invariant.js",

View File

@@ -9,11 +9,16 @@
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import {
bindSettingsScope, type ClientContext, type SettingsScope,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
import { en, zh, type CommonKey } from '../locales/index.ts'
import {
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
@@ -26,6 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
// The translate currency lives in ui-slots (the render machinery synthesizes
// the seat); re-exported here so dictionary owners import one package.
@@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
@@ -65,7 +68,7 @@ export interface LocaleSnapshot {
revision: number
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
locale: LocaleService
}
@@ -91,9 +94,6 @@ export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
@@ -116,13 +116,25 @@ export class LocaleService {
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
private readonly host: SettingsScope<LocaleSettings> | undefined
/** Browser-derived locale standing wherever no explicit Host selection does. */
private readonly provisional: LocaleId
/**
* @param ctx - owning context (change events are emitted on it).
* @param ctx - owning context (change events are emitted on it; the scope
* listener is released through ctx.effect on dispose).
* @param host - durable preference scope owned by the providing plugin;
* absent compositions (standalone dictionary registries) stay process-local.
*/
constructor(ctx: Context) {
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
this.ctx = ctx
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
this.host = host
this.provisional = resolveInitialLocale()
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
if (host !== undefined) {
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
this.adopt(host)
}
}
/**
@@ -155,16 +167,28 @@ export class LocaleService {
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
* Switch the active locale — the only user preference write entry.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
persistPreference(match.id)
this.publish(match.id, true)
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
}
/**
* Adopt the scope's accepted durable selection without writing it back; an
* absent selection returns to the browser-derived locale.
* @param host - the constructor-narrowed scope driving this adoption.
*/
private adopt(host: SettingsScope<LocaleSettings>): void {
const section = host.getSnapshot().value
if (section === undefined) return
const target = section.preference ?? this.provisional
if (this.snapshot.active === target) return
this.publish(target, true)
}
/**
@@ -288,27 +312,11 @@ export class LocaleService {
}
/**
* The locale a fresh service opens with: an explicit preference the user
* already chose wins over the browser's own language, which in turn wins over
* {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language
* this app does not ship).
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
* Host preference may replace this provisional value after plugin activation.
*/
function resolveInitialLocale(): LocaleId {
return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE
}
/** Read the persisted locale id; unknown or unreadable values read as no preference. */
function restorePreference(): LocaleId | undefined {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return undefined
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'zh' || stored === 'en') return stored
} catch {
// Storage access can throw (privacy mode); an unreadable store simply
// records no preference, and the browser language decides instead.
}
return undefined
return detectBrowserLocale() ?? FALLBACK_LOCALE
}
/**
@@ -325,8 +333,7 @@ function detectBrowserLocale(): LocaleId | undefined {
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
* The DOM lib types `languages` as always present; embedders and older
* WebViews ship a Navigator without it, and spreading undefined would
* throw at boot. Same environment-boundary distrust as the localStorage
* guards below. */
* throw at boot. */
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
const primary = tag.toLowerCase().split('-')[0]
const match = LOCALES.find(locale => locale.id === primary)
@@ -335,19 +342,8 @@ function detectBrowserLocale(): LocaleId | undefined {
return undefined
}
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
function persistPreference(id: LocaleId): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services: the slot registry (the feature registers its own settings row). */
export const inject = ['slots']
/** Required services: slot registration plus the settings transport. */
export const inject = ['slots', 'connection']
/**
* Client plugin body: provide the locale service with base dictionaries and
@@ -356,7 +352,8 @@ export const inject = ['slots']
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
const locale = new LocaleService(ctx, host)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)

View File

@@ -1,4 +1,23 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host registration for the browser locale preference. */
/** Host plugin body — no host-side behavior for the locale plugin. */
export function apply(): void {}
import type { Context } from '@deepseek-ai/cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts'
export {
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
type LocaleId, type LocaleSettings,
} from './locale-settings.ts'
/**
* Register the durable locale section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(LOCALE_SETTINGS_NAMESPACE),
LocaleSettingsSchema,
)
})
}

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'

View File

@@ -0,0 +1,26 @@
/** Locale preference stored in the Host user-settings document. */
import z from '@deepseek-ai/schemastery'
/** Settings namespace owned by the locale plugin. */
export const LOCALE_SETTINGS_NAMESPACE = 'locale'
/** Field carrying an explicit locale selection; absence delegates to the browser. */
export const LOCALE_PREFERENCE_FIELD = 'preference'
/** Locale identifiers shipped by the browser client. */
export const LOCALE_IDS = ['zh', 'en'] as const
/** Shipped locale identifier. */
export type LocaleId = typeof LOCALE_IDS[number]
/** Durable locale section shared by the Host schema and the browser scope. */
export interface LocaleSettings {
/** Explicit locale selection; absence delegates to the browser. */
preference?: LocaleId
}
/** Durable locale schema; also the wire envelope the browser scope validates against. */
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
})

View File

@@ -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 () => {

View 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)
})
})

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -20,6 +20,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}