wip
fix: docs
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
@@ -26,8 +27,10 @@ async function bench() {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear, binding, fork } as never)
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
|
||||
insertSessionBefore, open, clear, renameSession, binding, fork,
|
||||
}
|
||||
}
|
||||
@@ -42,7 +45,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void {
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale'])
|
||||
})
|
||||
|
||||
it('registers browser and pickers for declarations arriving before or after apply', async () => {
|
||||
@@ -50,6 +53,10 @@ describe('ui-workspace apply', () => {
|
||||
declare(before.slots, 'sidebar.workspaces')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
|
||||
// Copy rides the standard locale seat: the entry declares the namespace
|
||||
// and apply registered both dictionaries.
|
||||
expect(before.slots.entries('sidebar.workspaces')[0]!.locale).toBe('workspace')
|
||||
expect(before.locale.bind('workspace')('session.new')).toBe('新会话')
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
@@ -22,6 +23,15 @@ const SID = 's1' as SessionId
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
/** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
|
||||
async function createRuntime(): Promise<SlotTestRuntime> {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const locale = new LocaleService(runtime.ctx)
|
||||
runtime.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
return runtime
|
||||
}
|
||||
|
||||
/** Test-owned sidebar shell role: declares and renders the browsing region. */
|
||||
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
|
||||
function SidebarFrame({ renderSlot }: FrameProps) {
|
||||
@@ -30,7 +40,7 @@ function SidebarFrame({ renderSlot }: FrameProps) {
|
||||
|
||||
describe('session rename through the assembled browser', () => {
|
||||
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const runtime = await createRuntime()
|
||||
const rename = vi.fn<ISession['rename']>(async title => ({
|
||||
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
|
||||
}))
|
||||
@@ -54,20 +64,20 @@ describe('session rename through the assembled browser', () => {
|
||||
|
||||
// The current session's group auto-expands; open the row's action menu.
|
||||
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('会话“旧标题”的操作'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: '重命名', hidden: true }))
|
||||
|
||||
// The dialog seeds from the current title; submit a padded value.
|
||||
const input = await view.findByLabelText('Session name') as HTMLInputElement
|
||||
const input = await view.findByLabelText('会话名称') as HTMLInputElement
|
||||
expect(input.value).toBe('旧标题')
|
||||
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||
fireEvent.click(view.getByRole('button', { name: '重命名' }))
|
||||
|
||||
// The injected hop reached the session face with the edge-trimmed draft
|
||||
// (the dialog trims edges; interior normalization is host-side).
|
||||
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
|
||||
// Acceptance closes the dialog without any push-frame wait.
|
||||
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
|
||||
await waitFor(() => { expect(view.queryByLabelText('会话名称')).toBeNull() })
|
||||
// The manager lands the unary echo in the list store (its own package
|
||||
// tests own that hop); the row re-labels from list state alone.
|
||||
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
|
||||
@@ -77,7 +87,7 @@ describe('session rename through the assembled browser', () => {
|
||||
})
|
||||
|
||||
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const runtime = await createRuntime()
|
||||
const rename = vi.fn<ISession['rename']>(async () => ({
|
||||
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
|
||||
}))
|
||||
@@ -101,17 +111,17 @@ describe('session rename through the assembled browser', () => {
|
||||
await runtime.flush()
|
||||
|
||||
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
|
||||
const input = await view.findByLabelText('Session name')
|
||||
fireEvent.click(within(row as HTMLElement).getByLabelText('会话“旧标题”的操作'))
|
||||
fireEvent.click(view.getByRole('menuitem', { name: '重命名', hidden: true }))
|
||||
const input = await view.findByLabelText('会话名称')
|
||||
fireEvent.change(input, { target: { value: '新名' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
|
||||
fireEvent.click(view.getByRole('button', { name: '重命名' }))
|
||||
|
||||
// Failure: the injected hop rethrows the business error; the dialog
|
||||
// stays open with the alert and the row keeps its title.
|
||||
const alert = await view.findByRole('alert')
|
||||
expect(alert.textContent).toContain('title write failed')
|
||||
expect(view.getByLabelText('Session name')).toBeTruthy()
|
||||
expect(view.getByLabelText('会话名称')).toBeTruthy()
|
||||
expect(view.getByText('旧标题')).toBeTruthy()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Standard locale seat stub mirroring the real ns → common → key chain (zh default).
|
||||
const t = makeTranslate(zh, commonZh) as never
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
@@ -45,11 +51,11 @@ describe('workspace browser rows', () => {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
|
||||
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
@@ -58,18 +64,18 @@ describe('workspace browser rows', () => {
|
||||
|
||||
it('renders and opens a selected running Session row', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
|
||||
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
|
||||
}
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />,
|
||||
)
|
||||
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(row.hasAttribute('aria-expanded')).toBe(false)
|
||||
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onOpen).toHaveBeenCalledWith(node.id)
|
||||
})
|
||||
@@ -84,22 +90,22 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
render(<ProjectRowItem
|
||||
group={group} onToggle={onToggle} onCreate={vi.fn()}
|
||||
actions={{ rename: onRename, delete: onDelete }}
|
||||
actions={{ rename: onRename, delete: onDelete }} t={t}
|
||||
/>)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
|
||||
// Opening the menu neither toggles the group nor renames yet.
|
||||
expect(onToggle).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
@@ -111,13 +117,13 @@ describe('workspace browser rows', () => {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
// Card body: full title + cwd + absolute creation time.
|
||||
expect(screen.getAllByText('Project')).toHaveLength(2)
|
||||
expect(screen.getByText('/projects/project')).toBeTruthy()
|
||||
expect(screen.getByText(/^Created /)).toBeTruthy()
|
||||
expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -128,8 +134,8 @@ describe('workspace browser rows', () => {
|
||||
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
|
||||
expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename and fork', () => {
|
||||
@@ -137,27 +143,27 @@ describe('workspace browser rows', () => {
|
||||
const onRename = vi.fn()
|
||||
const onFork = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
onRename={onRename} onFork={onFork} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
|
||||
expect(onFork).toHaveBeenCalledWith(node.id)
|
||||
// Delete stays visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
@@ -166,23 +172,23 @@ describe('workspace browser rows', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
// Card body: full title + relative time + running status.
|
||||
expect(screen.getAllByText('Hovered')).toHaveLength(2)
|
||||
expect(screen.getByText('1min ago')).toBeTruthy()
|
||||
expect(screen.getByText('Running')).toBeTruthy()
|
||||
expect(screen.getByText('1分钟前')).toBeTruthy()
|
||||
expect(screen.getByText('进行中')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Menu open (disabled=true) suppresses the card for the same hover.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('1min ago')).toBeNull()
|
||||
expect(screen.queryByText('1分钟前')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -192,14 +198,14 @@ describe('workspace browser rows', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
expect(screen.getByText('now ago')).toBeTruthy()
|
||||
expect(screen.getByText('空闲')).toBeTruthy()
|
||||
expect(screen.getAllByText('刚刚')).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -207,12 +213,12 @@ describe('workspace browser rows', () => {
|
||||
|
||||
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0,
|
||||
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -230,7 +236,7 @@ describe('workspace browser rows', () => {
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -244,7 +250,7 @@ describe('workspace browser rows', () => {
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import { deriveFlat, deriveGroups, projectLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
@@ -52,26 +52,35 @@ describe('deriveGroups', () => {
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
|
||||
expect(groups[0]!.sessions.find(session => session.id === currentBlank.id)!.title).toBe('New Session')
|
||||
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
|
||||
// The stored placeholder title stays canonical; the renderer swaps in
|
||||
// the localized New Session label via the blank flag.
|
||||
expect(blankNode.title).toBe('New Session')
|
||||
expect(blankNode.blank).toBe(true)
|
||||
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
// A non-current blank stray never surfaces an Ungrouped bucket either.
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('searches the current blank session by its New Session title', () => {
|
||||
it('excludes blank sessions from search regardless of the query', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const real = { ...summary('real', 3), displayTitle: 'new session notes' }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
...list(currentBlank, staleBlank, real),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'),
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale', 'real'])],
|
||||
view([], 'new session'),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id])
|
||||
expect(groups[0]!.sessions[0]!.title).toBe('New Session')
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
// Only the real title hit matches; the current blank's placeholder title
|
||||
// never participates (it displays localized, so matching it would tie
|
||||
// search to one language).
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id])
|
||||
})
|
||||
|
||||
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
|
||||
@@ -168,7 +177,7 @@ describe('deriveFlat', () => {
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session with its New Session title', () => {
|
||||
it('shows only the current blank session and excludes blanks from search', () => {
|
||||
const currentBlank = { ...summary('current-blank', 9), blank: true }
|
||||
const staleBlank = { ...summary('stale-blank', 8), blank: true }
|
||||
const sessions = {
|
||||
@@ -178,7 +187,10 @@ describe('deriveFlat', () => {
|
||||
const rows = deriveFlat(sessions, { query: '' })
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id])
|
||||
expect(rows.map(row => row.blank)).toEqual([true, false])
|
||||
// Blank rows never match a query — not their placeholder title, not their id.
|
||||
expect(deriveFlat(sessions, { query: 'new session' })).toEqual([])
|
||||
expect(deriveFlat(sessions, { query: 'current-blank' })).toEqual([])
|
||||
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -202,14 +214,14 @@ describe('projectLabel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('formats current, minute, hour, day, month, and year buckets', () => {
|
||||
describe('relativeTime', () => {
|
||||
it('buckets current, minute, hour, day, month, and year distances', () => {
|
||||
const now = 400 * 24 * 60 * 60 * 1_000
|
||||
expect(formatRelativeTime(now, now)).toBe('now')
|
||||
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
|
||||
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
|
||||
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
|
||||
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
|
||||
expect(formatRelativeTime(0, now)).toBe('1y')
|
||||
expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 })
|
||||
expect(relativeTime(now - 5 * 60_000, now)).toEqual({ unit: 'minutes', n: 5 })
|
||||
expect(relativeTime(now - 3 * 3_600_000, now)).toEqual({ unit: 'hours', n: 3 })
|
||||
expect(relativeTime(now - 2 * 86_400_000, now)).toEqual({ unit: 'days', n: 2 })
|
||||
expect(relativeTime(now - 60 * 86_400_000, now)).toEqual({ unit: 'months', n: 2 })
|
||||
expect(relativeTime(0, now)).toEqual({ unit: 'years', n: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,20 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
// The seat's key domain is workspace ∪ common; the stub mirrors the real
|
||||
// lookup chain (namespace, then common vocabulary, then the key).
|
||||
const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
|
||||
@@ -63,6 +70,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||
t,
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -82,29 +90,29 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
|
||||
})
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
expect(screen.getByText('工作区')).toBeTruthy()
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
// Sessions hidden while their group is folded.
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
|
||||
expect(b.store.getSnapshot().groupBy).toBe('flat')
|
||||
expect(screen.getByText('Sessions')).toBeTruthy()
|
||||
expect(screen.getByText('会话')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha')).toBeNull()
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
expect(screen.getByText('beta-s')).toBeTruthy()
|
||||
|
||||
// Back to workspace grouping through the same menu.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(screen.getByText('Workspaces')).toBeTruthy()
|
||||
expect(screen.getByText('工作区')).toBeTruthy()
|
||||
|
||||
// Escape closes the menu without picking.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.store.getSnapshot().groupBy).toBe('workspace')
|
||||
@@ -134,7 +142,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
expect(screen.getByText('child-s')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
|
||||
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
@@ -147,7 +155,7 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
// The current-group effect expanded the owning group without a click.
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
|
||||
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
})
|
||||
|
||||
@@ -160,8 +168,8 @@ describe('WorkspaceBrowser', () => {
|
||||
})
|
||||
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
|
||||
expect(screen.getByText('loose')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
|
||||
expect(screen.queryByRole('button', { name: '工作区“未分组”的操作' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“未分组”中新建会话' }))
|
||||
expect(startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -180,7 +188,7 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only the current blank session as New Session in grouped, flat, and search modes', () => {
|
||||
it('shows only the current blank session as the localized New Session, excluded from search', () => {
|
||||
const currentBlank = summary('alpha-blank', 9, { blank: true })
|
||||
const staleBlank = summary('beta-blank', 8, { blank: true })
|
||||
const sessions = sessionState(
|
||||
@@ -193,18 +201,22 @@ describe('WorkspaceBrowser', () => {
|
||||
workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
|
||||
])),
|
||||
})
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
expect(screen.getByText('新会话')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha-blank')).toBeNull()
|
||||
expect(screen.queryByText('beta-blank')).toBeNull()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
|
||||
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
expect(screen.getAllByText('新会话')).toHaveLength(1)
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
expect(screen.getAllByText('新会话')).toHaveLength(1)
|
||||
// Search excludes blank rows entirely — neither the canonical stored
|
||||
// title nor the localized display label participates in matching.
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } })
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } })
|
||||
expect(screen.queryByText('新会话')).toBeNull()
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
@@ -216,14 +228,14 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
|
||||
})
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
|
||||
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
|
||||
fireEvent.change(input, { target: { value: 'needle' } })
|
||||
// Search forces matches visible without expansion state.
|
||||
expect(screen.getByText('Needle row')).toBeTruthy()
|
||||
expect(screen.queryByText('Other row')).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.getByText('无匹配结果')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(input.value).toBe('')
|
||||
// Clicking the field row focuses the input (wide mode).
|
||||
fireEvent.click(input.parentElement as HTMLElement)
|
||||
@@ -232,13 +244,13 @@ describe('WorkspaceBrowser', () => {
|
||||
|
||||
it('shows the no-sessions empty state in both modes', () => {
|
||||
const b = mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
expect(screen.getByText('暂无会话')).toBeTruthy()
|
||||
// Flat search misses show No matches.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
|
||||
expect(screen.getByText('无匹配结果')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rail state renders icon controls that request expansion', () => {
|
||||
@@ -247,17 +259,17 @@ describe('WorkspaceBrowser', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar })
|
||||
// No wide chrome in rail state.
|
||||
expect(screen.queryByText('Workspaces')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
expect(screen.queryByText('工作区')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
// The wide flip mounts the input and focuses it after the slide.
|
||||
rerender(b, { wide: true })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
const input = screen.getByPlaceholderText('搜索名称、关键词…')
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
// Wide search button is decorative (tabIndex -1, no expand call).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -267,17 +279,17 @@ describe('WorkspaceBrowser', () => {
|
||||
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
expect(expandSidebar).not.toHaveBeenCalled()
|
||||
// createOnly: existing workspaces are not listed, only the create actions.
|
||||
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
|
||||
// Toggle: open and close in place.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
// Escape closes the picker through its own onClose.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -398,20 +410,20 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('alert').textContent).toBe('已存在名为“Beta”的工作区。')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
// While renaming: input disabled, close blocked, Enter ignored.
|
||||
expect(input.disabled).toBe(true)
|
||||
@@ -427,9 +439,9 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
|
||||
// Enter with a blocked draft (unchanged) does nothing.
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(renameWorkspace).not.toHaveBeenCalled()
|
||||
@@ -441,7 +453,7 @@ describe('WorkspaceBrowser', () => {
|
||||
// The dialog stays for retry; typing clears the error; Cancel closes.
|
||||
fireEvent.change(input, { target: { value: 'Renamed2' } })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -451,10 +463,10 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
renameWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
fireEvent.change(screen.getByLabelText('工作区名称'), { target: { value: 'Other' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
})
|
||||
|
||||
@@ -465,31 +477,31 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
|
||||
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '删除工作区' })
|
||||
expect(dialog.textContent).toContain('将把“Alpha”从工作区列表中移除')
|
||||
expect(dialog.textContent).toContain('文件夹与会话记录会保留')
|
||||
expect(dialog.textContent).toContain('其会话将显示在“未分组”下')
|
||||
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: '删除工作区' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '取消' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('正在删除工作区…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
|
||||
await act(async () => { resolveDelete() })
|
||||
// RPC success alone does not close: the component waits until its
|
||||
// useWorkspaces projection has committed the removal, preventing a stale
|
||||
// duplicate-name frame from leaking into the next create gesture.
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
|
||||
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
|
||||
@@ -500,15 +512,15 @@ describe('WorkspaceBrowser', () => {
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
|
||||
deleteWorkspace,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
|
||||
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
|
||||
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
|
||||
})
|
||||
|
||||
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
|
||||
@@ -518,17 +530,17 @@ describe('WorkspaceBrowser', () => {
|
||||
deleteWorkspace,
|
||||
})
|
||||
const open = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
|
||||
}
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
open()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
expect(deleteWorkspace).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
|
||||
})
|
||||
|
||||
it('search hides drag affordances (rows are not draggable during search)', () => {
|
||||
@@ -537,7 +549,7 @@ describe('WorkspaceBrowser', () => {
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
|
||||
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
})
|
||||
|
||||
@@ -5,12 +5,19 @@ import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// The seat's key domain is workspace ∪ common; the stub mirrors the real
|
||||
// lookup chain (namespace, then common vocabulary, then the key).
|
||||
const t: WorkspacePickerProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
function workspace(id: string, title = id): WorkspaceView {
|
||||
return {
|
||||
@@ -91,6 +98,7 @@ function mount(
|
||||
createWorkspace={createWorkspace}
|
||||
useDirectoryFlow={occupancy.useDirectoryFlow}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
@@ -102,7 +110,7 @@ function mount(
|
||||
}
|
||||
}
|
||||
|
||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
||||
function chooseItem(name: '打开本地文件夹…' | '新建工作区'): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
@@ -117,10 +125,10 @@ describe('WorkspacePicker', () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
chooseItem('新建工作区')
|
||||
const input = screen.getByLabelText('新工作区名称')
|
||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
@@ -130,7 +138,7 @@ describe('WorkspacePicker', () => {
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
@@ -142,7 +150,7 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('treats flow cancellation as a silent no-op', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
act(() => { b.probe.owner!.onCancel() })
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
@@ -157,15 +165,15 @@ describe('WorkspacePicker', () => {
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
|
||||
expect(screen.getByRole('alert').textContent).toBe('请选择其他名称的文件夹。')
|
||||
// The failed adoption withdrew the flow; Choose again reopens it.
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
|
||||
expect(b.probe.owner!.open).toBe(true)
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -175,22 +183,22 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('adopted')
|
||||
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
// The flow is open but nothing is picked yet: a chooser pending on the
|
||||
// host display must already block concurrent workspace actions.
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
|
||||
act(() => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(b.probe.owner!.busy).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.probe.owner!.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the flow-reported failure in the folder-error surface', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
act(() => { b.probe.owner!.onError('no chooser installed') })
|
||||
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
@@ -199,18 +207,18 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('closes a creation modal when the user cancels', () => {
|
||||
mount([])
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
chooseItem('新建工作区')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
chooseItem('新建工作区')
|
||||
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('已存在名为“Alpha”的工作区。')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '创建工作区' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('新工作区名称'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -219,12 +227,12 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
chooseItem('新建工作区')
|
||||
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
|
||||
b.rerenderItems([created])
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
|
||||
@@ -235,12 +243,12 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const createWorkspace = vi.fn(() => pending)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
chooseItem('新建工作区')
|
||||
const input = screen.getByLabelText('新工作区名称')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.change(input, { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(createWorkspace).toHaveBeenCalledTimes(1)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -252,10 +260,10 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseItem('Create a new workspace')
|
||||
chooseItem('新建工作区')
|
||||
// The name field starts empty (no prefill); a name is required to submit.
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
})
|
||||
@@ -268,7 +276,7 @@ describe('WorkspacePicker', () => {
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -283,49 +291,49 @@ describe('WorkspacePicker', () => {
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
|
||||
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
expect(screen.getByRole('status').textContent).toBe('正在加载工作区…')
|
||||
})
|
||||
|
||||
it('hides the folder entry while the directory-flow hole is empty', () => {
|
||||
mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: '新建工作区' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the folder entry when a flow package activates after the first paint', () => {
|
||||
const b = mount([], vi.fn(), occupancySource(false))
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
|
||||
// Registration changes flow through the subscription, no re-render needed.
|
||||
act(() => { b.occupancy.flip(true) })
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() })
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() })
|
||||
// The occupant unloads while the error dialog is up: retrying would open
|
||||
// a flow nobody can serve or cancel, so the button goes inert.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重新选择' }).disabled).toBe(true)
|
||||
// Cancel stays the way out, and the menu actions are usable again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
chooseItem('打开本地文件夹…')
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
// The flow plugin unloads mid-interaction (HMR): nobody is left to
|
||||
// cancel, so the owner withdraws and the actions come back.
|
||||
act(() => { b.occupancy.flip(false) })
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
|
||||
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user