فهرست منبع

test(settings): exercise settings through the whole-client fixture

imccyu 3 هفته پیش
والد
کامیت
4ffc774e5c

+ 7 - 6
packages/client/ui-settings-general/package.json

@@ -49,22 +49,23 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@types/react": "~18.3.1",
-    "react": "^18.2.0",
-    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-remote-mock": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
-    "clsx": "^2.0.0"
+    "@types/react": "~18.3.1",
+    "clsx": "^2.0.0",
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 181 - 166
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -1,20 +1,30 @@
-/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+// @vitest-environment jsdom
+/**
+ * Ownerless-copy registrations inside the assembled web client: the five
+ * seats, the `settings` dictionaries, the locale-following nav label, the
+ * loopback-only document action over the real settings mirror, and recovery
+ * across Loader rebuilds of the declaring chain.
+ */
+import { describe, expect, onTestFinished, vi } from 'vitest'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
+import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts'
+import { inject } from '../src/client/index.ts'
 import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
 import { GeneralSection } from '../src/client/GeneralSection.tsx'
 import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
 import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
 
-// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
-// so browser-language detection never runs and a fresh LocaleRuntime opens on
-// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead.
+const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+const it = createClientTest({ roster: webApp })
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
+/** Dictionary namespace this plugin owns; every seat it fills declares it. */
+const NS = 'settings'
 
 /** The seats this plugin fills for a loopback browser (slot name → expected component). */
 const SEATS = [
@@ -25,56 +35,55 @@ const SEATS = [
   ['settings.section', GeneralSection],
 ] as const
 
-async function bench(isLoopback = true) {
-  const ctx = new Context()
-  await ctx.plugin(SlotRegistry).await()
-  const locale = new LocaleRuntime(ctx)
-  locale.setLocale('zh')
-  ctx.provide('locale', locale)
-  const settingsDescribe = vi.fn(() => Promise.resolve({
-    ok: true as const,
-    value: {
-      writable: true,
-      hasDocument: true,
-      namespaces: [],
-    },
-  }))
-  const settingsOpenDocument = vi.fn(() => Promise.resolve({
-    ok: true as const, value: { opened: true as const },
-  }))
-  const remote = new TestRemote(ctx, {
-    settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument },
-  })
-  // The fixed Host facts the shell reads its loopback-only action from.
-  remote.$host = { home: undefined, isLoopback }
-  ctx.provide('connection', {
-    state: { getSnapshot: () => 'connected', subscribe: () => () => {} },
-    reconnect: () => {},
-  } as never)
-  await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument }
+/** One Host view of the locale preference, including its revision fence. */
+function localeView(preference: string, revision = 0): SettingsNamespaceView {
+  return {
+    ns: LOCALE_SETTINGS_NAMESPACE,
+    // The Remote wire serializes nested Schema values before the client rehydrates them.
+    schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'],
+    value: { preference },
+    applies: 'live',
+    secrets: [],
+    revision,
+  }
+}
+
+async function client(mock: RemoteMock, start: () => Promise<TestClient>, hasDocument = false) {
+  const settings = mock.remote.settings
+  settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] }))
+  const c = await start()
+  // The locale adopts the Host preference once the describe mirror holds the document.
+  await c.ctx.settingsScope.describe().ensure()
+  return { c, settings }
+}
+
+/** This plugin's rows in a seat: the list seats also carry feature-owned rows (the product's other sections and actions). */
+function ownEntries(c: TestClient, name: (typeof SEATS)[number][0]) {
+  return c.ctx.slots.entries(name).filter(entry => entry.locale === NS)
+}
+
+function generalEntry(c: TestClient) {
+  return ownEntries(c, 'settings.section').find(entry => entry.component === GeneralSection)!
 }
 
-/** Declare the shell's six child slots the way ui-settings' entry does. */
-function declare(slots: SlotRegistry): () => void {
-  return slots.register(
-    {
-      name: 'root',
-      children: {
-        'settings.trigger': { kind: 'single', scope: 'root' },
-        'settings.header': { kind: 'single', scope: 'root' },
-        'settings.action': { kind: 'list', scope: 'root' },
-        'settings.close': { kind: 'single', scope: 'root' },
-        'settings.section': { kind: 'list', scope: 'root' },
-        'settings.onboarding': { kind: 'list', scope: 'root' },
-      },
-    } as never,
-    () => null,
-  )
+function generalLabel(c: TestClient): string | undefined {
+  return resolveSlotLabel(generalEntry(c).options.label)
 }
 
-function generalEntry(slots: SlotRegistry) {
-  return slots.entries('settings.section').find(e => e.component === GeneralSection)
+function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected {
+  const entry = ownEntries(c, 'settings.action')[0]!
+  return (entry.inject as unknown as () => SettingsDocumentActionInjected)()
+}
+
+function expectSeated(c: TestClient): void {
+  for (const [name, component] of SEATS) {
+    expect(ownEntries(c, name).map(entry => entry.component)).toEqual([component])
+  }
+}
+
+/** The page authority the `connection` plugin classifies at apply, reconfigured through the jsdom instance vitest exposes. */
+function setPageUrl(url: string): void {
+  (globalThis as unknown as { jsdom: { reconfigure(settings: { url: string }): void } }).jsdom.reconfigure({ url })
 }
 
 describe('ui-settings-general apply', () => {
@@ -82,138 +91,144 @@ describe('ui-settings-general apply', () => {
     expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
   })
 
-  it('fills all five seats for declarations before or after apply', async () => {
-    const before = await bench()
-    declare(before.slots)
-    await before.ctx.plugin({ inject: [...inject], apply }).await()
-    for (const [name, component] of SEATS) {
-      expect(before.slots.entries(name)[0]!.component).toBe(component)
-    }
-    const entry = generalEntry(before.slots)!
+  it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => {
+    const { c } = await client(mock, start)
+    expect(c.ctx.locale.getSnapshot().active).toBe('zh')
+    expectSeated(c)
+    const entry = generalEntry(c)
     expect(entry.options).toMatchObject({ id: 'general', order: 0 })
     // The nav label is a locale-following thunk; owners resolve at read time.
-    expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
-    expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
-    expect(before.slots.entries('settings.general.item')).toEqual([])
-    // The onboarding hole stays declared for feature-owned steps; this plugin
-    // no longer seats one.
-    expect(before.slots.entries('settings.onboarding')).toEqual([])
-    const action = before.slots.entries('settings.action')[0]!
-    const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
-    expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
-    expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store)
-    // Copy rides the standard locale seat: every seat declares the namespace.
-    for (const [name] of SEATS) {
-      expect(before.slots.entries(name)[0]!.locale).toBe('settings')
-    }
-    const after = await bench()
-    await after.ctx.plugin({ inject: [...inject], apply }).await()
-    for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0)
-    declare(after.slots)
-    await Promise.resolve()
+    expect(generalLabel(c)).toBe('通用设置')
+    expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    // The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own.
+    expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
+    expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([])
+    const { controller, hooks } = actionInjectedOf(c)
+    expect(controller.store.getSnapshot().status).toBe('idle')
+    expect(hooks.snapshot).toBe(controller.store)
+    // Copy rides the standard locale seat: every row this plugin seats declares the namespace.
     for (const [name, component] of SEATS) {
-      expect(after.slots.entries(name)[0]!.component).toBe(component)
-      // The self-inflicted ledger notifications hit the duplicate guard.
-      expect(after.slots.entries(name)).toHaveLength(1)
+      expect(c.ctx.slots.entries(name).find(row => row.component === component)!.locale).toBe(NS)
     }
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const english = localeView('en', 1)
+    settings.mutate.mockResolvedValueOnce(ok(english))
+    const t = c.ctx.locale.bind(NS)
+    expect(t('title')).toBe('设置')
+    expect(t('connection.error')).toBe('连接异常')
+    expect(t('connection.connecting')).toBe('自动重连中')
+    expect(t('connection.connected')).toBe('连接成功')
+    c.ctx.locale.setLocale('en')
+    expect(t('close')).toBe('Close')
+    expect(t('connection.reconnect')).toBe('Disconnected, reconnect now')
+    expect(t('connection.connecting')).toBe('Reconnecting')
     await vi.waitFor(() => {
-      expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english])
     })
-  })
-
-  it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.locale.bind('settings')('title')).toBe('设置')
-    expect(b.locale.bind('settings')('connection.error')).toBe('连接异常')
-    expect(b.locale.bind('settings')('connection.connecting')).toBe('自动重连中')
-    expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功')
-    b.locale.setLocale('en')
-    expect(b.locale.bind('settings')('close')).toBe('Close')
-    expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now')
-    expect(b.locale.bind('settings')('connection.connecting')).toBe('Reconnecting')
-    b.locale.setLocale('zh')
-    await fiber.dispose()
+    await c.unload(SELF)
+    await c.flush()
     // The (ns, locale) seats are free again — the dictionary disposer ran.
-    expect(() => b.locale.register('settings', 'zh', {})).not.toThrow()
-    expect(() => b.locale.register('settings', 'en', {})).not.toThrow()
+    expect(() => { c.ctx.locale.register(NS, 'zh', {})() }).not.toThrow()
+    expect(() => { c.ctx.locale.register(NS, 'en', {})() }).not.toThrow()
   })
 
-  it('the nav label thunk follows the active locale without re-registration', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name))
-    b.locale.setLocale('en')
+  it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const english = localeView('en', 1)
+    const chinese = localeView('zh', 2)
+    settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese))
+    const zhVersions = SEATS.map(([name]) => c.ctx.slots.getVersion(name))
+    c.ctx.locale.setLocale('en')
     // No ledger churn: freshness rides the thunk (and the renderer's locale
     // subscription), not re-registration.
     SEATS.forEach(([name], i) => {
-      expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
-      expect(b.slots.entries(name)).toHaveLength(1)
+      expect(c.ctx.slots.getVersion(name)).toBe(zhVersions[i]!)
+      expect(ownEntries(c, name)).toHaveLength(1)
+    })
+    expect(generalLabel(c)).toBe('General')
+    await vi.waitFor(() => {
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english])
+    })
+    c.ctx.locale.setLocale('zh')
+    expect(generalLabel(c)).toBe('通用设置')
+    await vi.waitFor(() => {
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese])
     })
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
-    b.locale.setLocale('zh')
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
   })
 
-  it('reads availability from the shared mirror and follows its reconnect refresh', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const entry = b.slots.entries('settings.action')[0]!
-    const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
-    // The mirror read once at its own boot; the action's load adds no read.
-    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() })
+  it('reads availability from the shared mirror and follows its reconnect refresh', async ({ mock, start }) => {
+    const { c } = await client(mock, start, true)
+    const { controller } = actionInjectedOf(c)
+    // Boot reads the document twice: the mirror's own `ensure` at apply, then
+    // the `connection/reset` of the first connection. The action's load adds none.
+    expect(c.mock.log.calls('settings/describe')).toHaveLength(2)
     await controller.load()
-    expect(b.settingsDescribe).toHaveBeenCalledOnce()
+    expect(c.mock.log.calls('settings/describe')).toHaveLength(2)
     expect(controller.store.getSnapshot().status).toBe('ready')
-    b.ctx.emit('connection/reset')
-    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
+    c.connection.reconnect()
+    await c.mock.streams.opened('$events', 2)
+    await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) })
   })
 
-  it('withholds the Host document action off-loopback', async () => {
-    const b = await bench(false)
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.slots.entries('settings.action')).toEqual([])
-    expect(b.settingsDescribe).not.toHaveBeenCalled()
-    await fiber.dispose()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
+  it('withholds the Host document action off-loopback', async ({ mock, start }) => {
+    const loopbackUrl = location.href
+    setPageUrl('http://198.51.100.7:3000/')
+    onTestFinished(() => { setPageUrl(loopbackUrl) })
+    const { c } = await client(mock, start)
+    expect(c.connection.isLoopback).toBe(false)
+    expect(ownEntries(c, 'settings.action')).toEqual([])
+    // Off-loopback settings stay process-local: no describe read, so the browser language stands.
+    expect(c.mock.log.calls('settings/describe')).toEqual([])
+    expect(c.ctx.locale.getSnapshot().active).toBe('en')
+    await c.unload(SELF)
+    await c.flush()
+    for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([])
   })
 
-  it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
-    const b = await bench()
-    const redeclare = declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    // Declarer unload: the cascade removes every seat entry and the item
-    // declaration while our local disposers go stale.
-    redeclare()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
-    declare(b.slots)
-    await Promise.resolve()
-    for (const [name, component] of SEATS) {
-      expect(b.slots.entries(name)[0]!.component).toBe(component)
-    }
-    expect(b.slots.entries('settings.general.item')).toEqual([])
-    expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+  it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => {
+    const { c, settings } = await client(mock, start)
+    const before = SEATS.map(([name]) => ownEntries(c, name)[0])
+    await c.reload(SIDEBAR)
+    await c.flush()
+    expectSeated(c)
+    SEATS.forEach(([name], index) => {
+      expect(ownEntries(c, name)[0]).not.toBe(before[index])
+    })
+    expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
+    expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([])
     // The recovered registrations still ride the locale path.
-    b.locale.setLocale('en')
-    expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
-    b.locale.setLocale('zh')
+    const english = localeView('en', 1)
+    const chinese = localeView('zh', 2)
+    settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese))
+    c.ctx.locale.setLocale('en')
+    expect(generalLabel(c)).toBe('General')
+    c.ctx.locale.setLocale('zh')
+    expect(generalLabel(c)).toBe('通用设置')
+    await vi.waitFor(() => {
+      expect(settings.mutate.mock.calls).toEqual([
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0],
+        [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1],
+      ])
+      expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese])
+    })
   })
 
-  it('removes every seat and the item declaration on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    expect(b.slots.spec('settings.general.item')).toBeDefined()
-    await fiber.dispose()
-    for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0)
-    expect(b.slots.spec('settings.general.item')).toBeUndefined()
+  it('removes every seat and the item declaration when its row unloads', async ({ mock, start }) => {
+    const { c } = await client(mock, start)
+    expect(c.ctx.slots.spec('settings.general.item')).toBeDefined()
+    await c.unload(SELF)
+    await c.flush()
+    for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0)
+    expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined()
   })
 })

+ 83 - 131
packages/client/ui-settings-general/tests/shell.client.spec.ts

@@ -1,54 +1,25 @@
-/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
-import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { apply, inject } from '../src/client/index.ts'
+// @vitest-environment jsdom
+/**
+ * Settings shell registration inside the assembled web client: the shell
+ * occupies the `sidebar.settings` hole ui-sidebar declares, its ledger
+ * projections read the product's real sections, its connection control is the
+ * roster's Connection, and it survives a Loader rebuild of the declarer.
+ */
+import { describe, expect, vi } from 'vitest'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import { inject } from '../src/client/index.ts'
 import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
 
-async function bench() {
-  const ctx = new Context()
-  await ctx.plugin(SlotRegistry).await()
-  // Copy machinery the shell only reads a revision from; the real locale
-  // plugin would drag its own settings-row dependencies into this bench.
-  ctx.provide('locale', {
-    register: () => () => {},
-    bind: () => (key: string) => key,
-    getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
-    subscribe: () => () => {},
-  } as never)
-  // The shell mounts ui-settings, which injects `remote.settings`; without the
-  // namespace provided its fiber parks and no slot is ever declared.
-  const settings = {
-    describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }),
-  }
-  const reconnect = vi.fn()
-  const connectionState = {
-    getSnapshot: () => 'connected' as const,
-    subscribe: () => () => {},
-  }
-  ctx.provide('connection', { state: connectionState, reconnect } as never)
-  ctx.provide('remote', {
-    $on: () => () => {},
-    $host: { home: undefined, isLoopback: false },
-    settings,
-  } as never)
-  ctx.provide('remote.settings', settings as never)
-  await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect }
-}
-
-function declare(slots: SlotRegistry): () => void {
-  return slots.register(
-    { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
-    () => null,
-  )
-}
+const SELF = '@deepseek-ai/dsh-client-ui-settings-general'
+const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar'
+const it = createClientTest({ roster: webApp })
+/** The whole roster's first boot pays the cold module transform of every plugin package. */
+const COLD_BOOT_TIMEOUT_MS = 60_000
 
-function injectedOf(slots: SlotRegistry): SettingsRootInjected {
-  const entry = slots.entries('sidebar.settings')[0]!
+function injectedOf(c: TestClient): SettingsRootInjected {
+  const entry = c.ctx.slots.entries('sidebar.settings')[0]!
   return (entry.inject as () => SettingsRootInjected)()
 }
 
@@ -61,82 +32,74 @@ const CHILD_SPECS = {
   'settings.section': { kind: 'list', scope: 'root' },
   'settings.onboarding': { kind: 'list', scope: 'root' },
 } as const
+const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>
 
-describe('ui-settings apply', () => {
-  it('declares only the slot registry (a pure composition face, no locale)', () => {
-    expect(inject).toEqual([
-      'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope',
-    ])
-  })
-
-  it('registers the shell and declares every child slot, before or after the declaration', async () => {
-    const before = await bench()
-    declare(before.slots)
-    await before.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
-    }
+/**
+ * Section ids the web-app roster registers, in nav order: this package, then
+ * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding
+ * a section changes this list.
+ */
+const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets']
+/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */
+const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [
+  { id: 'welcome-notice', order: -100 },
+  { id: 'deepseek-official', order: 0 },
+]
 
-    const after = await bench()
-    await after.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
-    declare(after.slots)
-    await Promise.resolve()
-    expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    // The self-inflicted ledger notifications hit the duplicate guard.
-    expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
+describe('ui-settings-general shell', () => {
+  it('declares its services', () => {
+    expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'])
   })
 
-  it('projects the section ledger into ordered nav rows with option defaults', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const { sections } = injectedOf(b.slots).hooks
-    // This package registers the General section itself; every other section
-    // arrives from a feature registrant.
-    const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
-    expect(sections.getSnapshot()).toEqual([GENERAL])
-    b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
-    // No order and no label: both projection defaults apply.
-    b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
+  it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => {
+    const c = await start()
+    expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot])
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
+  }, COLD_BOOT_TIMEOUT_MS)
+
+  it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => {
+    const c = await start()
+    const { sections } = injectedOf(c).hooks
+    const product = sections.getSnapshot()
+    expect(product.map(row => row.id)).toEqual(PRODUCT_SECTIONS)
+    expect(product[0]).toEqual({ id: 'general', order: 0, label: expect.any(String) as string })
+    c.ctx.slots.register({ name: 'settings.section', id: 'z', order: 1_000, label: 'Z' } as never, () => null)
+    // No order and no label: both projection defaults apply, and order 0 sorts among the product rows.
+    c.ctx.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
     const rows = sections.getSnapshot()
-    expect(rows).toEqual([
-      GENERAL,
-      { id: 'a', order: 0, label: '' },
-      { id: 'z', order: 20, label: 'Z' },
-    ])
+    expect(rows.at(-1)).toEqual({ id: 'z', order: 1_000, label: 'Z' })
+    expect(rows.find(row => row.id === 'a')).toEqual({ id: 'a', order: 0, label: '' })
+    expect(rows.map(row => row.order)).toEqual([...rows.map(row => row.order)].sort((x, y) => x - y))
     // Snapshot identity is stable until the ledger moves (uSES contract).
     expect(sections.getSnapshot()).toBe(rows)
     const listener = vi.fn()
     const off = sections.subscribe(listener)
-    b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalled()
     expect(sections.getSnapshot()).not.toBe(rows)
     off()
   })
 
-  it('projects the Gateway connection control without copying its state', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots)
-    expect(injected.hooks.connectionState).toBe(b.connectionState)
+  it('projects the roster Connection control without copying its state; reconnect opens a new $events generation', async ({ start }) => {
+    const c = await start()
+    const injected = injectedOf(c)
+    expect(injected.hooks.connectionState).toBe(c.connection.state)
+    expect(injected.hooks.connectionState.getSnapshot()).toBe('connected')
     injected.reconnect()
-    expect(b.reconnect).toHaveBeenCalledOnce()
+    await c.mock.streams.opened('$events', 2)
+    await vi.waitFor(() => { expect(c.connection.state.getSnapshot()).toBe('connected') })
   })
 
-  it('projects onboarding entries into stable coordinator order', async () => {
-    const b = await bench()
-    declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const { onboardingSteps } = injectedOf(b.slots).hooks
-    b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
-    b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
-    b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
+  it('projects onboarding entries into stable coordinator order', async ({ start }) => {
+    const c = await start()
+    const { onboardingSteps } = injectedOf(c).hooks
+    expect(onboardingSteps.getSnapshot()).toEqual(PRODUCT_ONBOARDING)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
     const steps = onboardingSteps.getSnapshot()
-    expect(steps).toEqual([
+    expect(steps.filter(step => !PRODUCT_ONBOARDING.some(known => known.id === step.id))).toEqual([
       { id: 'welcome', order: -100 },
       { id: 'credential', order: 0 },
       { id: 'default-order', order: 0 },
@@ -144,39 +107,28 @@ describe('ui-settings apply', () => {
     expect(onboardingSteps.getSnapshot()).toBe(steps)
     const listener = vi.fn()
     const off = onboardingSteps.subscribe(listener)
-    b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
+    c.ctx.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalledOnce()
     off()
   })
 
-  it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
-    const b = await bench()
-    const redeclare = declare(b.slots)
-    await b.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
-    // Declarer unload: the cascade removes our entry and every child
-    // declaration while our local disposer variable goes stale.
-    redeclare()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
-    expect(b.slots.spec('settings.trigger')).toBeUndefined()
-    declare(b.slots)
-    await Promise.resolve()
-    expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
-    }
+  it('re-registers after the declarer reloads: the cascade removes the shell, the rebuilt declaration takes it back', async ({ start }) => {
+    const c = await start()
+    const before = c.ctx.slots.entries('sidebar.settings')[0]
+    expect(before).toBeDefined()
+    await c.reload(SIDEBAR)
+    await c.flush()
+    expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot])
+    expect(c.ctx.slots.entries('sidebar.settings')[0]).not.toBe(before)
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name])
   })
 
-  it('unregisters the shell and collapses every child slot on teardown', async () => {
-    const b = await bench()
-    declare(b.slots)
-    const fiber = b.ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    await fiber.dispose()
-    expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
-    for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
-      expect(b.slots.spec(name)).toBeUndefined()
-    }
+  it('unregisters the shell and collapses every child slot when its row unloads', async ({ start }) => {
+    const c = await start()
+    await c.unload(SELF)
+    await c.flush()
+    expect(c.ctx.slots.entries('sidebar.settings')).toHaveLength(0)
+    for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toBeUndefined()
   })
 })

+ 3 - 0
pnpm-lock.yaml

@@ -3628,6 +3628,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-remote-mock':
+        specifier: workspace:^
+        version: link:../../test-support/remote-mock
       '@deepseek-ai/dsh-settings':
         specifier: workspace:^
         version: link:../../settings/settings