Files
deepseek-harness/packages/client/ui-layout/tests/apply.client.spec.ts
T

244 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import ShortcutsService from '../../shortcuts/src/client/index.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { HostObservable, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
import type { MainPanelId } from '../src/client/service.ts'
import type { createLayoutStore } from '../src/client/stores.ts'
import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
const owners = new Set<Fiber>()
let originalRootStyle: string | null
let originalBodyStyle: string | null
let originalDarkTheme: string | null
let originalThemeMetadata: Element[]
beforeEach(() => {
originalRootStyle = document.documentElement.getAttribute('style')
originalBodyStyle = document.body.getAttribute('style')
originalDarkTheme = document.body.getAttribute('data-ds-dark-theme')
originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')]
originalThemeMetadata.forEach((node) => { node.remove() })
vi.stubGlobal('innerWidth', 1920)
})
afterEach(async () => {
try {
for (const owner of owners) await owner.dispose()
} finally {
owners.clear()
restoreAttribute(document.documentElement, 'style', originalRootStyle)
restoreAttribute(document.body, 'style', originalBodyStyle)
restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme)
document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
document.head.append(...originalThemeMetadata)
vi.unstubAllGlobals()
}
})
function restoreAttribute(element: Element, name: string, value: string | null): void {
if (value === null) element.removeAttribute(name)
else element.setAttribute(name, value)
}
async function bench() {
const root = new Context()
let ctx: Context | undefined
const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext })
owners.add(owner)
await owner.await()
if (ctx === undefined) throw new Error('the fixture owner did not activate')
const slotsFiber = ctx.plugin(SlotRegistry)
ctx.provide('locale', new LocaleRuntime(ctx))
await ctx.plugin(ShortcutsService).await()
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never)
await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
await slotsFiber.await()
const slots = ctx.get('slots') as SlotRegistry
let host: SlotRendererHost | undefined
slots.install({ renderRoot: (value) => { host = value; return null } })
const rendererHost = (): SlotRendererHost => {
slots.renderSlot('root', {})
if (host === undefined) throw new Error('the root renderer did not receive its host')
return host
}
return { ctx, slots, rendererHost }
}
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toEqual(['slots', 'theme', 'locale', 'shortcuts'])
})
it('provides ctx.layout and declares the root-scoped frame slots', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutController)
expect(slots.entries('root')).toHaveLength(1)
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' })
expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('shell.bottom')).toEqual({ kind: 'single', scope: 'root' })
expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' })
expect(slots.spec('shell.leading')).toEqual({ kind: 'single', scope: 'root' })
})
it('keeps Desktop sidebar shortcuts behind a foreground modal', async () => {
const { ctx, slots } = await bench()
const shortcuts = ctx.get('shortcuts')
if (shortcuts === undefined) throw new Error('shortcuts service is unavailable')
const register = vi.spyOn(shortcuts, 'register')
await ctx.plugin({ inject: [...inject], apply }).await()
const command = register.mock.calls.find(([entry]) => entry.id === 'sidebar.left.toggle')?.[0]
if (command === undefined) throw new Error('sidebar shortcut was not registered')
const registry = new ShortcutRegistry('desktop', 'macos')
registry.register(command)
const handle = slots.entries('root')[0]!.store as ReturnType<typeof createLayoutStore>
const width = () => handle.create().getSnapshot().layoutInfo.sidebar
const gesture = { code: 'KeyB', meta: true, control: false, alt: false, shift: false,
repeat: false, composing: false, defaultPrevented: false }
const context = { region: 'editable' as const, target: null, modal: 'settings' }
expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass')
expect(width()).toBe(280)
expect(registry.dispatch(gesture, { ...context, modal: null }, vi.fn()).status).toBe('handled')
expect(width()).toBe(0)
expect(registry.dispatch(gesture, { ...context, modal: 'sidebar' }, vi.fn()).status).toBe('handled')
expect(width()).toBe(280)
})
it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => {
const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('root')[0]!
expect(entry.inject).toBeTypeOf('function')
const handle = entry.store as ReturnType<typeof createLayoutStore>
const instance = handle.create()
expect(handle.create()).toBe(instance)
const layout = ctx.get('layout') as LayoutController
expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered')
layout.toggleSidebar()
expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
const host = rendererHost()
expect(host.storeOf(entry, undefined)).toBe(instance)
const panelInfo = host.root.getSnapshot().hooks.panelInfo!
expect(panelInfo).toBe(layout.panelInfo)
expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo)
const panelId = 'panel-a' as MainPanelId
const disposePanel = slots.register({ name: 'main', key: panelId }, () => null)
layout.selectPanel(panelId)
expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId })
disposePanel()
await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) })
expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered')
expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
const pending = layout.beginNavigation()
await fiber.dispose()
expect(pending.aborted).toBe(true)
})
it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
const { ctx } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
// Initial getter application: jsdom has no matchMedia, system resolves light.
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
const themeColorMeta = document.head.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
expect(themeColorMeta).not.toBeNull()
const theme = ctx.get('theme') as ThemeRuntime
theme.setTheme('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true)
expect(document.head.querySelector('meta[name="theme-color"]')).toBe(themeColorMeta)
await fiber.dispose()
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
expect(themeColorMeta?.isConnected).toBe(false)
// Listener is off: further theme changes no longer reach the document.
theme.setTheme('light')
theme.setTheme('dark')
expect(document.documentElement.style.colorScheme).toBe('')
expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false)
})
it('injects one live folded-landscape source and releases each subscription', async () => {
const query = Object.assign(new EventTarget(), { matches: true })
vi.stubGlobal('matchMedia', (media: string) => media === '(device-posture: folded) and (orientation: landscape)'
? query : Object.assign(new EventTarget(), { matches: false }))
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('root')[0]
if (entry?.inject === undefined) throw new Error('the root entry must inject its media source')
const first = entry.inject() as { hooks: { foldedLandscape: HostObservable<boolean> } }
const second = entry.inject() as typeof first
const source = first.hooks.foldedLandscape
expect(second.hooks.foldedLandscape).toBe(source)
expect(source.getSnapshot()).toBe(true)
const listener = vi.fn()
const unsubscribe = source.subscribe(listener)
try {
query.matches = false
query.dispatchEvent(new Event('change'))
expect(source.getSnapshot()).toBe(false)
expect(listener).toHaveBeenCalledOnce()
} finally { unsubscribe() }
query.matches = true
query.dispatchEvent(new Event('change'))
expect(listener).toHaveBeenCalledOnce()
})
it('provides ordinary layout when the client tree starts without browser media queries', async () => {
vi.stubGlobal('matchMedia', undefined)
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('root')[0]
if (entry?.inject === undefined) throw new Error('the root entry must inject its media source')
const source = (entry.inject() as { hooks: { foldedLandscape: HostObservable<boolean> } }).hooks.foldedLandscape
expect(source.getSnapshot()).toBe(false)
const listener = vi.fn()
source.subscribe(listener)()
expect(listener).not.toHaveBeenCalled()
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots, rendererHost } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const host = rendererHost()
expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined()
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()
expect(slots.entries('root')).toHaveLength(0)
expect(slots.spec('sidebar')).toBeUndefined()
expect(slots.spec('main')).toBeUndefined()
expect(slots.spec('rightbar')).toBeUndefined()
expect(slots.spec('shell.bottom')).toBeUndefined()
expect(slots.spec('shell.overlay')).toBeUndefined()
expect(slots.spec('shell.leading')).toBeUndefined()
expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined()
// The built-in root declaration survives entry teardown (renderer-owned).
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
})
})
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
})