Files
deepseek-harness/packages/client/ui-slash/tests/apply.spec.ts
creatixchu cb754a0319 feat(locale): derive the initial Settings language from the browser
A first visit resolved to Chinese regardless of the browser: LocaleService
read `dsh.locale` and fell straight back to `zh` when nothing was stored,
ignoring the languages the browser already states it reads.

The initial locale now resolves through three ordered sources — the persisted
preference, then `navigator` (first entry of the ordered language list whose
primary subtag names a shipped locale, so `zh-Hans-CN` -> zh and `en-GB` ->
en), then `FALLBACK_LOCALE`. An explicit choice still wins and nothing writes
the detected locale back to storage, so "has the user chosen?" stays a
question only the stored value answers.

Specs asserting the shipped Chinese copy now state the browser they assume:
the web e2e scenarios open their page with `locale: ZH_BROWSER_LOCALE`, and
package specs pin it through the new `pinBrowserLanguages` test helper.
`settings-chrome.e2e.ts` gains an English-browser scenario as the
assembled-app proof.
2026-07-31 15:26:46 +08:00

113 lines
5.1 KiB
TypeScript

/**
* apply wiring on a real cordis Context + SlotsService: SlashService mounts
* as ctx.slash once its sessions dependency is up; the MenuView overlay
* registration waits on the conversation seam (ctx.inject scope), lands once
* the declarer is up, resolves the per-session controller from the slot's
* sessionId, and unregisters on fiber teardown.
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { pinBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-slash/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
let restoreLanguages: () => void
beforeEach(() => { restoreLanguages = pinBrowserLanguages('zh-CN') })
afterEach(() => { restoreLanguages() })
const sid = (k: string): SessionId => k as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
// Stand-in for the ui-conversation composer entry: declare the overlay
// slot, then provide the conversation service (declaration precedes the
// service exactly as the real apply orders them).
slots.register(
{ name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } } } as never,
() => null,
)
// Sessions face: mint one real scope for session 'a' and resolve it by id.
const scope = createScope(ctx, sid('a'))
ctx.provide('sessions', {
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
scopeOf: (c: Context) => scopeOf(c),
})
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots, locale }
}
describe('apply', () => {
it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => {
expect(inject).toEqual(['sessions', 'locale'])
})
it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => {
const { ctx, locale } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const t = locale.bind('slash.menu')
expect(t('command')).toBe('命令')
locale.setLocale('en')
expect(t('skill')).toBe('Skills')
expect(t('subagent')).toBe('Subagents')
expect(t('loading')).toBe('Loading…')
})
it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => {
const { ctx } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
expect(ctx.get('slash')).toBeInstanceOf(SlashService)
})
it('registers MenuView into the overlay and resolves the per-session controller by slot sessionId', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
ctx.provide('conversation', {})
// The inject scope activates asynchronously on the service arrival.
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
const entries = slots.entries('conversation.input.overlay')
expect(entries[0]!.options.id).toBe('slash-menu')
// Copy rides the standard locale seat, not the business face.
expect(entries[0]!.locale).toBe('slash.menu')
const slash = ctx.get('slash') as SlashService
// StoredEntry.inject is declaration-typed ((...args: never[]) shape);
// the erased registration widens it past a direct cast, so hop unknown.
const injectEntry = entries[0]!.inject as unknown as (sessionId: SessionId) => MenuViewInjected
const injected = injectEntry(sid('a'))
const controller = slash.sessionOf(
(ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')),
)
expect(injected.menu).toBe(controller.menu)
// The pick face routes into the controller pipeline (closed menu → no-op).
injected.onPick('command', 0)
expect(controller.menu.getSnapshot().open).toBe(false)
// The dismiss face routes into the controller too (closed menu → no-op).
injected.onDismiss()
expect(controller.menu.getSnapshot().open).toBe(false)
// An unknown session id fails loud (no silent scope miss).
expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/)
})
it('fiber teardown removes the overlay entry', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
ctx.provide('conversation', {})
await vi.waitFor(() => { expect(slots.entries('conversation.input.overlay')).toHaveLength(1) })
await fiber.dispose()
expect(slots.entries('conversation.input.overlay')).toHaveLength(0)
expect(ctx.get('slash')).toBeUndefined()
})
})